Compare commits

..

3 Commits

Author SHA1 Message Date
Stefan Agner
87e1e7a3ab Exclude already-existing layers from pull progress calculation
Layers that already exist locally should not count towards download
progress since there's nothing to download for them. Only layers that
need pulling are included in the progress calculation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 21:19:12 +01:00
Stefan Agner
e7c8700db9 Fix pytest 2025-12-01 21:19:12 +01:00
Stefan Agner
a4f681586e Use count-based progress for Docker image pulls
Refactor Docker image pull progress to use a simpler count-based approach
where each layer contributes equally (100% / total_layers) regardless of
size. This replaces the previous size-weighted calculation that was
susceptible to progress regression.

The core issue was that Docker rate-limits concurrent downloads (~3 at a
time) and reports layer sizes only when downloading starts. With size-
weighted progress, large layers appearing late would cause progress to
drop dramatically (e.g., 59% -> 29%) as the total size increased.

The new approach:
- Each layer contributes equally to overall progress
- Per-layer progress: 70% download weight, 30% extraction weight
- Progress only starts after first "Downloading" event (when layer
  count is known)
- Always caps at 99% - job completion handles final 100%

This simplifies the code by moving progress tracking to a dedicated
module (pull_progress.py) and removing complex size-based scaling logic
that tried to account for unknown layer sizes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 21:19:03 +01:00
165 changed files with 3751 additions and 7812 deletions

View File

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

View File

@@ -233,8 +233,6 @@ async def backup_full(self, request: web.Request) -> dict[str, Any]:
- **Fixtures**: Extensive use of pytest fixtures for CoreSys setup
- **Mocking**: Mock external dependencies (Docker, D-Bus, network calls)
- **Coverage**: Minimum 90% test coverage, 100% for security-sensitive code
- **Style**: Use plain `test_` functions, not `Test*` classes — test classes are
considered legacy style in this project
### Error Handling
@@ -278,14 +276,12 @@ Always run the pre-commit hooks at the end of code editing.
- Access Docker via `self.sys_docker` not direct Docker API
- Use constants from `const.py` instead of hardcoding
- Store types in (per-module) `const.py` (e.g. supervisor/store/const.py)
- Use relative imports within the `supervisor/` package (e.g., `from ..docker.manager import ExecReturn`)
**❌ Avoid These Patterns**:
- Direct Docker API usage - use Supervisor's Docker manager
- Blocking operations in async context (use asyncio alternatives)
- Hardcoded values - use constants from `const.py`
- Manual error handling in API endpoints - let `@api_process` handle it
- Absolute imports within the `supervisor/` package (e.g., `from supervisor.docker.manager import ...`) - use relative imports instead
This guide provides the foundation for contributing to Home Assistant Supervisor.
Follow these patterns and guidelines to ensure code quality, security, and

View File

@@ -53,10 +53,10 @@ jobs:
version: ${{ steps.version.outputs.version }}
channel: ${{ steps.version.outputs.channel }}
publish: ${{ steps.version.outputs.publish }}
build_wheels: ${{ steps.requirements.outputs.build_wheels }}
requirements: ${{ steps.requirements.outputs.changed }}
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
@@ -72,28 +72,20 @@ jobs:
- name: Get changed files
id: changed_files
if: github.event_name == 'pull_request' || github.event_name == 'push'
if: steps.version.outputs.publish == 'false'
uses: masesgroup/retrieve-changed-files@491e80760c0e28d36ca6240a27b1ccb8e1402c13 # v3.0.0
- name: Check if requirements files changed
id: requirements
run: |
# No wheels build necessary for releases
if [[ "${{ github.event_name }}" == "release" ]]; then
echo "build_wheels=false" >> "$GITHUB_OUTPUT"
# Always build wheels for manual dispatches
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "build_wheels=true" >> "$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"
if [[ "${{ steps.changed_files.outputs.all }}" =~ (requirements.txt|build.yaml) ]]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
build:
name: Build ${{ matrix.arch }} supervisor
needs: init
runs-on: ${{ matrix.runs-on }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
@@ -101,66 +93,34 @@ jobs:
strategy:
matrix:
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:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
- name: Write env-file for wheels build
if: needs.init.outputs.build_wheels == 'true'
- name: Write env-file
if: needs.init.outputs.requirements == 'true'
run: |
(
# Fix out of memory issues with rust
echo "CARGO_NET_GIT_FETCH_WITH_CLI=true"
) > .env_file
- name: Build and publish wheels
if: needs.init.outputs.build_wheels == 'true' && needs.init.outputs.publish == 'true'
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
# home-assistant/wheels doesn't support sha pinning
- name: Build wheels
if: needs.init.outputs.requirements == 'true'
uses: home-assistant/wheels@2025.11.0
with:
abi: cp313
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
abi: ${{ env.WHEELS_ABI }}
tag: ${{ env.WHEELS_TAG }}
arch: ${{ matrix.arch }}
apk: ${{ env.WHEELS_APK_DEPS }}
skip-binary: ${{ env.WHEELS_SKIP_BINARY }}
apk: "libffi-dev;openssl-dev;yaml-dev"
skip-binary: aiohttp
env-file: true
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
if: needs.init.outputs.publish == 'true'
uses: home-assistant/actions/helpers/version@master
@@ -169,7 +129,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.publish == 'true'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -193,7 +153,7 @@ jobs:
- name: Login to GitHub Container Registry
if: needs.init.outputs.publish == 'true'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -207,7 +167,6 @@ jobs:
- name: Build supervisor
uses: home-assistant/builder@2025.11.0
with:
image: ${{ matrix.arch }}
args: |
$BUILD_ARGS \
--${{ matrix.arch }} \
@@ -222,7 +181,7 @@ jobs:
steps:
- name: Checkout the repository
if: needs.init.outputs.publish == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Initialize git
if: needs.init.outputs.publish == 'true'
@@ -247,14 +206,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.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
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
# home-assistant/builder doesn't support sha pinning
- name: Build the Supervisor
@@ -293,69 +245,15 @@ jobs:
- name: Start the Supervisor
run: docker start hassio_supervisor
- &wait_for_supervisor
name: Wait for Supervisor to come up
- name: Wait for Supervisor to come up
run: |
until SUPERVISOR=$(docker inspect --format='{{.NetworkSettings.Networks.hassio.IPAddress}}' hassio_supervisor 2>/dev/null) && \
[ -n "$SUPERVISOR" ] && [ "$SUPERVISOR" != "<no value>" ]; do
echo "Waiting for network configuration..."
sleep 1
done
echo "Waiting for Supervisor API at http://${SUPERVISOR}/supervisor/ping"
timeout=300
elapsed=0
while [ $elapsed -lt $timeout ]; do
if response=$(curl -sSf "http://${SUPERVISOR}/supervisor/ping" 2>/dev/null); then
if echo "$response" | jq -e '.result == "ok"' >/dev/null 2>&1; then
echo "Supervisor is up! (took ${elapsed}s)"
exit 0
fi
fi
if [ $((elapsed % 15)) -eq 0 ]; then
echo "Still waiting... (${elapsed}s/${timeout}s)"
fi
SUPERVISOR=$(docker inspect --format='{{.NetworkSettings.IPAddress}}' hassio_supervisor)
ping="error"
while [ "$ping" != "ok" ]; do
ping=$(curl -sSL "http://$SUPERVISOR/supervisor/ping" | jq -r '.result')
sleep 5
elapsed=$((elapsed + 5))
done
echo "ERROR: Supervisor failed to start within ${timeout}s"
echo "Last response: $response"
echo "Checking supervisor logs..."
docker logs --tail 50 hassio_supervisor
exit 1
# Wait for Core to come up so subsequent steps (backup, addon install) succeed.
# On first startup, Supervisor installs Core via the "home_assistant_core_install"
# job (which pulls the image and then starts Core). Jobs with cleanup=True are
# removed from the jobs list once done, so we poll until it's gone.
- name: Wait for Core to be started
run: |
echo "Waiting for Home Assistant Core to be installed and started..."
timeout=300
elapsed=0
while [ $elapsed -lt $timeout ]; do
jobs=$(docker exec hassio_cli ha jobs info --no-progress --raw-json | jq -r '.data.jobs[] | select(.name == "home_assistant_core_install" and .done == false) | .name' 2>/dev/null)
if [ -z "$jobs" ]; then
echo "Home Assistant Core install/start complete (took ${elapsed}s)"
exit 0
fi
if [ $((elapsed % 15)) -eq 0 ]; then
echo "Core still installing... (${elapsed}s/${timeout}s)"
fi
sleep 5
elapsed=$((elapsed + 5))
done
echo "ERROR: Home Assistant Core failed to install/start within ${timeout}s"
docker logs --tail 50 hassio_supervisor
exit 1
- name: Check the Supervisor
run: |
echo "Checking supervisor info"
@@ -370,28 +268,28 @@ jobs:
exit 1
fi
- name: Check the Store / App
- name: Check the Store / Addon
run: |
echo "Install Core SSH app"
test=$(docker exec hassio_cli ha apps install core_ssh --no-progress --raw-json | jq -r '.result')
echo "Install Core SSH Add-on"
test=$(docker exec hassio_cli ha addons install core_ssh --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then
exit 1
fi
# Make sure it actually installed
test=$(docker exec hassio_cli ha apps info core_ssh --no-progress --raw-json | jq -r '.data.version')
test=$(docker exec hassio_cli ha addons info core_ssh --no-progress --raw-json | jq -r '.data.version')
if [[ "$test" == "null" ]]; then
exit 1
fi
echo "Start Core SSH app"
test=$(docker exec hassio_cli ha apps start core_ssh --no-progress --raw-json | jq -r '.result')
echo "Start Core SSH Add-on"
test=$(docker exec hassio_cli ha addons start core_ssh --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then
exit 1
fi
# Make sure its state is started
test="$(docker exec hassio_cli ha apps info core_ssh --no-progress --raw-json | jq -r '.data.state')"
test="$(docker exec hassio_cli ha addons info core_ssh --no-progress --raw-json | jq -r '.data.state')"
if [ "$test" != "started" ]; then
exit 1
fi
@@ -405,9 +303,9 @@ jobs:
fi
echo "slug=$(echo $test | jq -r '.data.slug')" >> "$GITHUB_OUTPUT"
- name: Uninstall SSH app
- name: Uninstall SSH add-on
run: |
test=$(docker exec hassio_cli ha apps uninstall core_ssh --no-progress --raw-json | jq -r '.result')
test=$(docker exec hassio_cli ha addons uninstall core_ssh --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then
exit 1
fi
@@ -419,23 +317,30 @@ jobs:
exit 1
fi
- *wait_for_supervisor
- name: Restore SSH app from backup
- name: Wait for Supervisor to come up
run: |
test=$(docker exec hassio_cli ha backups restore ${{ steps.backup.outputs.slug }} --app core_ssh --no-progress --raw-json | jq -r '.result')
SUPERVISOR=$(docker inspect --format='{{.NetworkSettings.IPAddress}}' hassio_supervisor)
ping="error"
while [ "$ping" != "ok" ]; do
ping=$(curl -sSL "http://$SUPERVISOR/supervisor/ping" | jq -r '.result')
sleep 5
done
- name: Restore SSH add-on from backup
run: |
test=$(docker exec hassio_cli ha backups restore ${{ steps.backup.outputs.slug }} --addons core_ssh --no-progress --raw-json | jq -r '.result')
if [ "$test" != "ok" ]; then
exit 1
fi
# Make sure it actually installed
test=$(docker exec hassio_cli ha apps info core_ssh --no-progress --raw-json | jq -r '.data.version')
test=$(docker exec hassio_cli ha addons info core_ssh --no-progress --raw-json | jq -r '.data.version')
if [[ "$test" == "null" ]]; then
exit 1
fi
# Make sure its state is started
test="$(docker exec hassio_cli ha apps info core_ssh --no-progress --raw-json | jq -r '.data.state')"
test="$(docker exec hassio_cli ha addons info core_ssh --no-progress --raw-json | jq -r '.data.state')"
if [ "$test" != "started" ]; then
exit 1
fi
@@ -468,7 +373,7 @@ jobs:
FROZEN_VERSION: "2025.11.5"
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}

View File

@@ -26,15 +26,15 @@ jobs:
name: Prepare Python dependencies
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: venv
key: |
@@ -48,7 +48,7 @@ jobs:
pip install -r requirements.txt -r requirements_tests.txt
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true
@@ -68,15 +68,15 @@ jobs:
needs: prepare
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: venv
key: |
@@ -88,7 +88,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@@ -111,15 +111,15 @@ jobs:
needs: prepare
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: venv
key: |
@@ -131,7 +131,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@@ -154,7 +154,7 @@ jobs:
needs: prepare
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Register hadolint problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/hadolint.json"
@@ -169,15 +169,15 @@ jobs:
needs: prepare
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: venv
key: |
@@ -189,7 +189,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@@ -213,15 +213,15 @@ jobs:
needs: prepare
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: venv
key: |
@@ -233,7 +233,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@@ -257,15 +257,15 @@ jobs:
needs: prepare
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: venv
key: |
@@ -293,9 +293,9 @@ jobs:
needs: prepare
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
id: python
with:
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
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: venv
key: >-
@@ -318,7 +318,7 @@ jobs:
echo "Failed to restore Python virtual environment from cache"
exit 1
- name: Restore mypy cache
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: .mypy_cache
key: >-
@@ -339,9 +339,9 @@ jobs:
name: Run tests Python ${{ needs.prepare.outputs.python-version }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
@@ -351,7 +351,7 @@ jobs:
cosign-release: "v2.5.3"
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: venv
key: |
@@ -386,7 +386,7 @@ jobs:
-o console_output_style=count \
tests
- name: Upload coverage artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: coverage
path: .coverage
@@ -398,15 +398,15 @@ jobs:
needs: ["pytest", "prepare"]
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: venv
key: |
@@ -417,7 +417,7 @@ jobs:
echo "Failed to restore Python virtual environment from cache"
exit 1
- name: Download all coverage artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: coverage
path: coverage/
@@ -428,4 +428,4 @@ jobs:
coverage report
coverage xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1

View File

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

View File

@@ -11,7 +11,7 @@ jobs:
name: Release Drafter
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
fetch-depth: 0
@@ -36,7 +36,7 @@ jobs:
echo "version=$datepre.$newpost" >> "$GITHUB_OUTPUT"
- name: Run Release Drafter
uses: release-drafter/release-drafter@6db134d15f3909ccc9eefd369f02bd1e9cffdf97 # v6.2.0
uses: release-drafter/release-drafter@b1476f6e6eb133afa41ed8589daba6dc69b4d3f5 # v6.1.0
with:
tag: ${{ steps.version.outputs.version }}
name: ${{ steps.version.outputs.version }}

View File

@@ -10,9 +10,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Sentry Release
uses: getsentry/action-release@dab6548b3c03c4717878099e43782cf5be654289 # v3.5.0
uses: getsentry/action-release@128c5058bbbe93c8e02147fe0a9c713f166259a6 # v3.4.0
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}

View File

@@ -9,7 +9,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 30

82
.github/workflows/update_frontend.yml vendored Normal file
View File

@@ -0,0 +1,82 @@
name: Update frontend
on:
schedule: # once a day
- cron: "0 0 * * *"
workflow_dispatch:
jobs:
check-version:
runs-on: ubuntu-latest
outputs:
skip: ${{ steps.check_version.outputs.skip || steps.check_existing_pr.outputs.skip }}
current_version: ${{ steps.check_version.outputs.current_version }}
latest_version: ${{ steps.latest_frontend_version.outputs.latest_tag }}
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Get latest frontend release
id: latest_frontend_version
uses: abatilo/release-info-action@32cb932219f1cee3fc4f4a298fd65ead5d35b661 # v1.3.3
with:
owner: home-assistant
repo: frontend
- name: Check if version is up to date
id: check_version
run: |
current_version="$(cat .ha-frontend-version)"
latest_version="${{ steps.latest_frontend_version.outputs.latest_tag }}"
echo "current_version=${current_version}" >> $GITHUB_OUTPUT
echo "LATEST_VERSION=${latest_version}" >> $GITHUB_ENV
if [[ ! "$current_version" < "$latest_version" ]]; then
echo "Frontend version is up to date"
echo "skip=true" >> $GITHUB_OUTPUT
fi
- name: Check if there is no open PR with this version
if: steps.check_version.outputs.skip != 'true'
id: check_existing_pr
env:
GH_TOKEN: ${{ github.token }}
run: |
PR=$(gh pr list --state open --base main --json title --search "Update frontend to version $LATEST_VERSION")
if [[ "$PR" != "[]" ]]; then
echo "Skipping - There is already a PR open for version $LATEST_VERSION"
echo "skip=true" >> $GITHUB_OUTPUT
fi
create-pr:
runs-on: ubuntu-latest
needs: check-version
if: needs.check-version.outputs.skip != 'true'
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Clear www folder
run: |
rm -rf supervisor/api/panel/*
- name: Update version file
run: |
echo "${{ needs.check-version.outputs.latest_version }}" > .ha-frontend-version
- name: Download release assets
uses: robinraju/release-downloader@daf26c55d821e836577a15f77d86ddc078948b05 # v1.12
with:
repository: 'home-assistant/frontend'
tag: ${{ needs.check-version.outputs.latest_version }}
fileName: home_assistant_frontend_supervisor-${{ needs.check-version.outputs.latest_version }}.tar.gz
extract: true
out-file-path: supervisor/api/panel/
- name: Remove release assets archive
run: |
rm -f supervisor/api/panel/home_assistant_frontend_supervisor-*.tar.gz
- name: Create PR
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
with:
commit-message: "Update frontend to version ${{ needs.check-version.outputs.latest_version }}"
branch: autoupdate-frontend
base: main
draft: true
sign-commits: true
title: "Update frontend to version ${{ needs.check-version.outputs.latest_version }}"
body: >
Update frontend from ${{ needs.check-version.outputs.current_version }} to
[${{ needs.check-version.outputs.latest_version }}](https://github.com/home-assistant/frontend/releases/tag/${{ needs.check-version.outputs.latest_version }})

5
.gitignore vendored
View File

@@ -24,9 +24,6 @@ var/
.installed.cfg
*.egg
# Local wheels
wheels/**/*.whl
# PyInstaller
# 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.
@@ -105,4 +102,4 @@ ENV/
/.dmypy.json
# Mac
.DS_Store
.DS_Store

1
.ha-frontend-version Normal file
View File

@@ -0,0 +1 @@
20250925.1

View File

@@ -7,6 +7,9 @@ ENV \
CRYPTOGRAPHY_OPENSSL_NO_LEGACY=1 \
UV_SYSTEM_PYTHON=true
ARG \
COSIGN_VERSION
# Install base
WORKDIR /usr/src
RUN \
@@ -22,22 +25,14 @@ RUN \
openssl \
yaml \
\
&& pip3 install uv==0.9.18
&& curl -Lso /usr/bin/cosign "https://github.com/home-assistant/cosign/releases/download/${COSIGN_VERSION}/cosign_${BUILD_ARCH}" \
&& chmod a+x /usr/bin/cosign \
&& pip3 install uv==0.8.9
# Install requirements
RUN \
--mount=type=bind,source=./requirements.txt,target=/usr/src/requirements.txt \
--mount=type=bind,source=./wheels,target=/usr/src/wheels \
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 \
LOCAL_WHEELS=; \
echo "No local wheels found"; \
fi && \
uv pip install --compile-bytecode --no-cache --no-build \
-r requirements.txt \
${LOCAL_WHEELS:+--find-links $LOCAL_WHEELS}
uv pip install --compile-bytecode --no-cache --no-build -r requirements.txt
# Install Home Assistant Supervisor
COPY . supervisor

View File

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

View File

@@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools~=82.0.0", "wheel~=0.46.1"]
requires = ["setuptools~=80.9.0", "wheel~=0.46.1"]
build-backend = "setuptools.build_meta"
[project]
@@ -9,7 +9,7 @@ license = { text = "Apache-2.0" }
description = "Open-source private cloud os for Home-Assistant based on HassOS"
readme = "README.md"
authors = [
{ name = "The Home Assistant Authors", email = "hello@home-assistant.io" },
{ name = "The Home Assistant Authors", email = "hello@home-assistant.io" },
]
keywords = ["docker", "home-assistant", "api"]
requires-python = ">=3.13.0"
@@ -53,154 +53,154 @@ good-names = ["id", "i", "j", "k", "ex", "Run", "_", "fp", "T", "os"]
# too-few-* - same as too-many-*
# unused-argument - generic callbacks and setup methods create a lot of warnings
disable = [
"format",
"abstract-method",
"cyclic-import",
"duplicate-code",
"locally-disabled",
"no-else-return",
"not-context-manager",
"too-few-public-methods",
"too-many-arguments",
"too-many-branches",
"too-many-instance-attributes",
"too-many-lines",
"too-many-locals",
"too-many-public-methods",
"too-many-return-statements",
"too-many-statements",
"unused-argument",
"consider-using-with",
"format",
"abstract-method",
"cyclic-import",
"duplicate-code",
"locally-disabled",
"no-else-return",
"not-context-manager",
"too-few-public-methods",
"too-many-arguments",
"too-many-branches",
"too-many-instance-attributes",
"too-many-lines",
"too-many-locals",
"too-many-public-methods",
"too-many-return-statements",
"too-many-statements",
"unused-argument",
"consider-using-with",
# Handled by ruff
# Ref: <https://github.com/astral-sh/ruff/issues/970>
"await-outside-async", # PLE1142
"bad-str-strip-call", # PLE1310
"bad-string-format-type", # PLE1307
"bidirectional-unicode", # PLE2502
"continue-in-finally", # PLE0116
"duplicate-bases", # PLE0241
"format-needs-mapping", # F502
"function-redefined", # F811
# Needed because ruff does not understand type of __all__ generated by a function
# "invalid-all-format", # PLE0605
"invalid-all-object", # PLE0604
"invalid-character-backspace", # PLE2510
"invalid-character-esc", # PLE2513
"invalid-character-nul", # PLE2514
"invalid-character-sub", # PLE2512
"invalid-character-zero-width-space", # PLE2515
"logging-too-few-args", # PLE1206
"logging-too-many-args", # PLE1205
"missing-format-string-key", # F524
"mixed-format-string", # F506
"no-method-argument", # N805
"no-self-argument", # N805
"nonexistent-operator", # B002
"nonlocal-without-binding", # PLE0117
"not-in-loop", # F701, F702
"notimplemented-raised", # F901
"return-in-init", # PLE0101
"return-outside-function", # F706
"syntax-error", # E999
"too-few-format-args", # F524
"too-many-format-args", # F522
"too-many-star-expressions", # F622
"truncated-format-string", # F501
"undefined-all-variable", # F822
"undefined-variable", # F821
"used-prior-global-declaration", # PLE0118
"yield-inside-async-function", # PLE1700
"yield-outside-function", # F704
"anomalous-backslash-in-string", # W605
"assert-on-string-literal", # PLW0129
"assert-on-tuple", # F631
"bad-format-string", # W1302, F
"bad-format-string-key", # W1300, F
"bare-except", # E722
"binary-op-exception", # PLW0711
"cell-var-from-loop", # B023
# "dangerous-default-value", # B006, ruff catches new occurrences, needs more work
"duplicate-except", # B014
"duplicate-key", # F601
"duplicate-string-formatting-argument", # F
"duplicate-value", # F
"eval-used", # PGH001
"exec-used", # S102
# "expression-not-assigned", # B018, ruff catches new occurrences, needs more work
"f-string-without-interpolation", # F541
"forgotten-debug-statement", # T100
"format-string-without-interpolation", # F
# "global-statement", # PLW0603, ruff catches new occurrences, needs more work
"global-variable-not-assigned", # PLW0602
"implicit-str-concat", # ISC001
"import-self", # PLW0406
"inconsistent-quotes", # Q000
"invalid-envvar-default", # PLW1508
"keyword-arg-before-vararg", # B026
"logging-format-interpolation", # G
"logging-fstring-interpolation", # G
"logging-not-lazy", # G
"misplaced-future", # F404
"named-expr-without-context", # PLW0131
"nested-min-max", # PLW3301
# "pointless-statement", # B018, ruff catches new occurrences, needs more work
"raise-missing-from", # TRY200
# "redefined-builtin", # A001, ruff is way more stricter, needs work
"try-except-raise", # TRY203
"unused-argument", # ARG001, we don't use it
"unused-format-string-argument", #F507
"unused-format-string-key", # F504
"unused-import", # F401
"unused-variable", # F841
"useless-else-on-loop", # PLW0120
"wildcard-import", # F403
"bad-classmethod-argument", # N804
"consider-iterating-dictionary", # SIM118
"empty-docstring", # D419
"invalid-name", # N815
"line-too-long", # E501, disabled globally
"missing-class-docstring", # D101
"missing-final-newline", # W292
"missing-function-docstring", # D103
"missing-module-docstring", # D100
"multiple-imports", #E401
"singleton-comparison", # E711, E712
"subprocess-run-check", # PLW1510
"superfluous-parens", # UP034
"ungrouped-imports", # I001
"unidiomatic-typecheck", # E721
"unnecessary-direct-lambda-call", # PLC3002
"unnecessary-lambda-assignment", # PLC3001
"unneeded-not", # SIM208
"useless-import-alias", # PLC0414
"wrong-import-order", # I001
"wrong-import-position", # E402
"comparison-of-constants", # PLR0133
"comparison-with-itself", # PLR0124
# "consider-alternative-union-syntax", # UP007, typing extension
"consider-merging-isinstance", # PLR1701
# "consider-using-alias", # UP006, typing extension
"consider-using-dict-comprehension", # C402
"consider-using-generator", # C417
"consider-using-get", # SIM401
"consider-using-set-comprehension", # C401
"consider-using-sys-exit", # PLR1722
"consider-using-ternary", # SIM108
"literal-comparison", # F632
"property-with-parameters", # PLR0206
"super-with-arguments", # UP008
"too-many-branches", # PLR0912
"too-many-return-statements", # PLR0911
"too-many-statements", # PLR0915
"trailing-comma-tuple", # COM818
"unnecessary-comprehension", # C416
"use-a-generator", # C417
"use-dict-literal", # C406
"use-list-literal", # C405
"useless-object-inheritance", # UP004
"useless-return", # PLR1711
# "no-self-use", # PLR6301 # Optional plugin, not enabled
# Handled by ruff
# Ref: <https://github.com/astral-sh/ruff/issues/970>
"await-outside-async", # PLE1142
"bad-str-strip-call", # PLE1310
"bad-string-format-type", # PLE1307
"bidirectional-unicode", # PLE2502
"continue-in-finally", # PLE0116
"duplicate-bases", # PLE0241
"format-needs-mapping", # F502
"function-redefined", # F811
# Needed because ruff does not understand type of __all__ generated by a function
# "invalid-all-format", # PLE0605
"invalid-all-object", # PLE0604
"invalid-character-backspace", # PLE2510
"invalid-character-esc", # PLE2513
"invalid-character-nul", # PLE2514
"invalid-character-sub", # PLE2512
"invalid-character-zero-width-space", # PLE2515
"logging-too-few-args", # PLE1206
"logging-too-many-args", # PLE1205
"missing-format-string-key", # F524
"mixed-format-string", # F506
"no-method-argument", # N805
"no-self-argument", # N805
"nonexistent-operator", # B002
"nonlocal-without-binding", # PLE0117
"not-in-loop", # F701, F702
"notimplemented-raised", # F901
"return-in-init", # PLE0101
"return-outside-function", # F706
"syntax-error", # E999
"too-few-format-args", # F524
"too-many-format-args", # F522
"too-many-star-expressions", # F622
"truncated-format-string", # F501
"undefined-all-variable", # F822
"undefined-variable", # F821
"used-prior-global-declaration", # PLE0118
"yield-inside-async-function", # PLE1700
"yield-outside-function", # F704
"anomalous-backslash-in-string", # W605
"assert-on-string-literal", # PLW0129
"assert-on-tuple", # F631
"bad-format-string", # W1302, F
"bad-format-string-key", # W1300, F
"bare-except", # E722
"binary-op-exception", # PLW0711
"cell-var-from-loop", # B023
# "dangerous-default-value", # B006, ruff catches new occurrences, needs more work
"duplicate-except", # B014
"duplicate-key", # F601
"duplicate-string-formatting-argument", # F
"duplicate-value", # F
"eval-used", # PGH001
"exec-used", # S102
# "expression-not-assigned", # B018, ruff catches new occurrences, needs more work
"f-string-without-interpolation", # F541
"forgotten-debug-statement", # T100
"format-string-without-interpolation", # F
# "global-statement", # PLW0603, ruff catches new occurrences, needs more work
"global-variable-not-assigned", # PLW0602
"implicit-str-concat", # ISC001
"import-self", # PLW0406
"inconsistent-quotes", # Q000
"invalid-envvar-default", # PLW1508
"keyword-arg-before-vararg", # B026
"logging-format-interpolation", # G
"logging-fstring-interpolation", # G
"logging-not-lazy", # G
"misplaced-future", # F404
"named-expr-without-context", # PLW0131
"nested-min-max", # PLW3301
# "pointless-statement", # B018, ruff catches new occurrences, needs more work
"raise-missing-from", # TRY200
# "redefined-builtin", # A001, ruff is way more stricter, needs work
"try-except-raise", # TRY203
"unused-argument", # ARG001, we don't use it
"unused-format-string-argument", #F507
"unused-format-string-key", # F504
"unused-import", # F401
"unused-variable", # F841
"useless-else-on-loop", # PLW0120
"wildcard-import", # F403
"bad-classmethod-argument", # N804
"consider-iterating-dictionary", # SIM118
"empty-docstring", # D419
"invalid-name", # N815
"line-too-long", # E501, disabled globally
"missing-class-docstring", # D101
"missing-final-newline", # W292
"missing-function-docstring", # D103
"missing-module-docstring", # D100
"multiple-imports", #E401
"singleton-comparison", # E711, E712
"subprocess-run-check", # PLW1510
"superfluous-parens", # UP034
"ungrouped-imports", # I001
"unidiomatic-typecheck", # E721
"unnecessary-direct-lambda-call", # PLC3002
"unnecessary-lambda-assignment", # PLC3001
"unneeded-not", # SIM208
"useless-import-alias", # PLC0414
"wrong-import-order", # I001
"wrong-import-position", # E402
"comparison-of-constants", # PLR0133
"comparison-with-itself", # PLR0124
# "consider-alternative-union-syntax", # UP007, typing extension
"consider-merging-isinstance", # PLR1701
# "consider-using-alias", # UP006, typing extension
"consider-using-dict-comprehension", # C402
"consider-using-generator", # C417
"consider-using-get", # SIM401
"consider-using-set-comprehension", # C401
"consider-using-sys-exit", # PLR1722
"consider-using-ternary", # SIM108
"literal-comparison", # F632
"property-with-parameters", # PLR0206
"super-with-arguments", # UP008
"too-many-branches", # PLR0912
"too-many-return-statements", # PLR0911
"too-many-statements", # PLR0915
"trailing-comma-tuple", # COM818
"unnecessary-comprehension", # C416
"use-a-generator", # C417
"use-dict-literal", # C406
"use-list-literal", # C405
"useless-object-inheritance", # UP004
"useless-return", # PLR1711
# "no-self-use", # PLR6301 # Optional plugin, not enabled
]
[tool.pylint.REPORTS]
@@ -226,120 +226,120 @@ log_date_format = "%Y-%m-%d %H:%M:%S"
asyncio_default_fixture_loop_scope = "function"
asyncio_mode = "auto"
filterwarnings = [
"error",
"ignore:pkg_resources is deprecated as an API:DeprecationWarning:dirhash",
"ignore::pytest.PytestUnraisableExceptionWarning",
"error",
"ignore:pkg_resources is deprecated as an API:DeprecationWarning:dirhash",
"ignore::pytest.PytestUnraisableExceptionWarning",
]
markers = [
"no_mock_init_websession: disable the autouse mock of init_websession for this test",
"no_mock_init_websession: disable the autouse mock of init_websession for this test",
]
[tool.ruff]
lint.select = [
"B002", # Python does not support the unary prefix increment
"B007", # Loop control variable {name} not used within loop body
"B014", # Exception handler with duplicate exception
"B023", # Function definition does not bind loop variable {name}
"B026", # Star-arg unpacking after a keyword argument is strongly discouraged
"B904", # Use raise from to specify exception cause
"C", # complexity
"COM818", # Trailing comma on bare tuple prohibited
"D", # docstrings
"DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow()
"DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts)
"E", # pycodestyle
"F", # pyflakes/autoflake
"G", # flake8-logging-format
"I", # isort
"ICN001", # import concentions; {name} should be imported as {asname}
"N804", # First argument of a class method should be named cls
"N805", # First argument of a method should be named self
"N815", # Variable {name} in class scope should not be mixedCase
"PGH004", # Use specific rule codes when using noqa
"PLC0414", # Useless import alias. Import alias does not rename original package.
"PLC", # pylint
"PLE", # pylint
"PLR", # pylint
"PLW", # pylint
"Q000", # Double quotes found but single quotes preferred
"RUF006", # Store a reference to the return value of asyncio.create_task
"S102", # Use of exec detected
"S103", # bad-file-permissions
"S108", # hardcoded-temp-file
"S306", # suspicious-mktemp-usage
"S307", # suspicious-eval-usage
"S313", # suspicious-xmlc-element-tree-usage
"S314", # suspicious-xml-element-tree-usage
"S315", # suspicious-xml-expat-reader-usage
"S316", # suspicious-xml-expat-builder-usage
"S317", # suspicious-xml-sax-usage
"S318", # suspicious-xml-mini-dom-usage
"S319", # suspicious-xml-pull-dom-usage
"S601", # paramiko-call
"S602", # subprocess-popen-with-shell-equals-true
"S604", # call-with-shell-equals-true
"S608", # hardcoded-sql-expression
"S609", # unix-command-wildcard-injection
"SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass
"SIM117", # Merge with-statements that use the same scope
"SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys()
"SIM201", # Use {left} != {right} instead of not {left} == {right}
"SIM208", # Use {expr} instead of not (not {expr})
"SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a}
"SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'.
"SIM401", # Use get from dict with default instead of an if block
"T100", # Trace found: {name} used
"T20", # flake8-print
"TID251", # Banned imports
"TRY004", # Prefer TypeError exception for invalid type
"TRY203", # Remove exception handler; error is immediately re-raised
"UP", # pyupgrade
"W", # pycodestyle
"B002", # Python does not support the unary prefix increment
"B007", # Loop control variable {name} not used within loop body
"B014", # Exception handler with duplicate exception
"B023", # Function definition does not bind loop variable {name}
"B026", # Star-arg unpacking after a keyword argument is strongly discouraged
"B904", # Use raise from to specify exception cause
"C", # complexity
"COM818", # Trailing comma on bare tuple prohibited
"D", # docstrings
"DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow()
"DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts)
"E", # pycodestyle
"F", # pyflakes/autoflake
"G", # flake8-logging-format
"I", # isort
"ICN001", # import concentions; {name} should be imported as {asname}
"N804", # First argument of a class method should be named cls
"N805", # First argument of a method should be named self
"N815", # Variable {name} in class scope should not be mixedCase
"PGH004", # Use specific rule codes when using noqa
"PLC0414", # Useless import alias. Import alias does not rename original package.
"PLC", # pylint
"PLE", # pylint
"PLR", # pylint
"PLW", # pylint
"Q000", # Double quotes found but single quotes preferred
"RUF006", # Store a reference to the return value of asyncio.create_task
"S102", # Use of exec detected
"S103", # bad-file-permissions
"S108", # hardcoded-temp-file
"S306", # suspicious-mktemp-usage
"S307", # suspicious-eval-usage
"S313", # suspicious-xmlc-element-tree-usage
"S314", # suspicious-xml-element-tree-usage
"S315", # suspicious-xml-expat-reader-usage
"S316", # suspicious-xml-expat-builder-usage
"S317", # suspicious-xml-sax-usage
"S318", # suspicious-xml-mini-dom-usage
"S319", # suspicious-xml-pull-dom-usage
"S601", # paramiko-call
"S602", # subprocess-popen-with-shell-equals-true
"S604", # call-with-shell-equals-true
"S608", # hardcoded-sql-expression
"S609", # unix-command-wildcard-injection
"SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass
"SIM117", # Merge with-statements that use the same scope
"SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys()
"SIM201", # Use {left} != {right} instead of not {left} == {right}
"SIM208", # Use {expr} instead of not (not {expr})
"SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a}
"SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'.
"SIM401", # Use get from dict with default instead of an if block
"T100", # Trace found: {name} used
"T20", # flake8-print
"TID251", # Banned imports
"TRY004", # Prefer TypeError exception for invalid type
"TRY203", # Remove exception handler; error is immediately re-raised
"UP", # pyupgrade
"W", # pycodestyle
]
lint.ignore = [
"D202", # No blank lines allowed after function docstring
"D203", # 1 blank line required before class docstring
"D213", # Multi-line docstring summary should start at the second line
"D406", # Section name should end with a newline
"D407", # Section name underlining
"E501", # line too long
"E731", # do not assign a lambda expression, use a def
"D202", # No blank lines allowed after function docstring
"D203", # 1 blank line required before class docstring
"D213", # Multi-line docstring summary should start at the second line
"D406", # Section name should end with a newline
"D407", # Section name underlining
"E501", # line too long
"E731", # do not assign a lambda expression, use a def
# Ignore ignored, as the rule is now back in preview/nursery, which cannot
# be ignored anymore without warnings.
# https://github.com/astral-sh/ruff/issues/7491
# "PLC1901", # Lots of false positives
# Ignore ignored, as the rule is now back in preview/nursery, which cannot
# be ignored anymore without warnings.
# https://github.com/astral-sh/ruff/issues/7491
# "PLC1901", # Lots of false positives
# False positives https://github.com/astral-sh/ruff/issues/5386
"PLC0208", # Use a sequence type instead of a `set` when iterating over values
"PLR0911", # Too many return statements ({returns} > {max_returns})
"PLR0912", # Too many branches ({branches} > {max_branches})
"PLR0913", # Too many arguments to function call ({c_args} > {max_args})
"PLR0915", # Too many statements ({statements} > {max_statements})
"PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable
"PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target
"UP006", # keep type annotation style as is
"UP007", # keep type annotation style as is
# False positives https://github.com/astral-sh/ruff/issues/5386
"PLC0208", # Use a sequence type instead of a `set` when iterating over values
"PLR0911", # Too many return statements ({returns} > {max_returns})
"PLR0912", # Too many branches ({branches} > {max_branches})
"PLR0913", # Too many arguments to function call ({c_args} > {max_args})
"PLR0915", # Too many statements ({statements} > {max_statements})
"PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable
"PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target
"UP006", # keep type annotation style as is
"UP007", # keep type annotation style as is
# May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
"W191",
"E111",
"E114",
"E117",
"D206",
"D300",
"Q000",
"Q001",
"Q002",
"Q003",
"COM812",
"COM819",
"ISC001",
"ISC002",
# May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
"W191",
"E111",
"E114",
"E117",
"D206",
"D300",
"Q000",
"Q001",
"Q002",
"Q003",
"COM812",
"COM819",
"ISC001",
"ISC002",
# Disabled because ruff does not understand type of __all__ generated by a function
"PLE0605",
# Disabled because ruff does not understand type of __all__ generated by a function
"PLE0605",
]
[tool.ruff.lint.flake8-import-conventions.extend-aliases]
@@ -354,11 +354,11 @@ fixture-parentheses = false
[tool.ruff.lint.isort]
force-sort-within-sections = true
section-order = [
"future",
"standard-library",
"third-party",
"first-party",
"local-folder",
"future",
"standard-library",
"third-party",
"first-party",
"local-folder",
]
forced-separate = ["tests"]
known-first-party = ["supervisor", "tests"]

View File

@@ -1,32 +1,32 @@
aiodns==4.0.0
aiodocker==0.25.0
aiohttp==3.13.3
aiodns==3.5.0
aiodocker==0.24.0
aiohttp==3.13.2
atomicwrites-homeassistant==1.4.1
attrs==25.4.0
awesomeversion==25.8.0
backports.zstd==1.3.0
blockbuster==1.5.26
backports.zstd==1.1.0
blockbuster==1.5.25
brotli==1.2.0
ciso8601==2.3.3
colorlog==6.10.1
cpe==1.3.1
cryptography==46.0.5
debugpy==1.8.20
cryptography==46.0.3
debugpy==1.8.17
deepmerge==2.0
dirhash==0.5.0
docker==7.1.0
faust-cchardet==2.1.19
gitpython==3.1.46
gitpython==3.1.45
jinja2==3.1.6
log-rate-limit==1.4.2
orjson==3.11.7
orjson==3.11.4
pulsectl==24.12.0
pyudev==0.24.4
PyYAML==6.0.3
requests==2.32.5
securetar==2025.12.0
sentry-sdk==2.52.0
setuptools==82.0.0
voluptuous==0.16.0
dbus-fast==4.0.0
securetar==2025.2.1
sentry-sdk==2.46.0
setuptools==80.9.0
voluptuous==0.15.2
dbus-fast==3.1.2
zlib-fast==0.2.1

View File

@@ -1,16 +1,16 @@
astroid==4.0.3
coverage==7.13.4
mypy==1.19.1
pre-commit==4.5.1
astroid==4.0.2
coverage==7.12.0
mypy==1.19.0
pre-commit==4.5.0
pylint==4.0.4
pytest-aiohttp==1.1.0
pytest-asyncio==1.3.0
pytest-cov==7.0.0
pytest-timeout==2.4.0
pytest==9.0.2
ruff==0.15.1
time-machine==3.2.0
types-docker==7.1.0.20260109
pytest==9.0.1
ruff==0.14.7
time-machine==3.1.0
types-docker==7.1.0.20251129
types-pyyaml==6.0.12.20250915
types-requests==2.32.4.20260107
urllib3==2.6.3
types-requests==2.32.4.20250913
urllib3==2.5.0

View File

@@ -15,15 +15,17 @@ import secrets
import shutil
import tarfile
from tempfile import TemporaryDirectory
from typing import Any, Final, cast
from typing import Any, Final
import aiohttp
from awesomeversion import AwesomeVersion, AwesomeVersionCompareException
from deepmerge import Merger
from securetar import AddFileError, SecureTarFile, atomic_contents_add, secure_path
from securetar import AddFileError, atomic_contents_add, secure_path
import voluptuous as vol
from voluptuous.humanize import humanize_error
from supervisor.utils.dt import utc_from_timestamp
from ..bus import EventListener
from ..const import (
ATTR_ACCESS_TOKEN,
@@ -61,28 +63,16 @@ from ..const import (
from ..coresys import CoreSys
from ..docker.addon import DockerAddon
from ..docker.const import ContainerState
from ..docker.manager import ExecReturn
from ..docker.monitor import DockerContainerStateEvent
from ..docker.stats import DockerStats
from ..exceptions import (
AddonBackupMetadataInvalidError,
AddonBuildFailedUnknownError,
AddonConfigurationInvalidError,
AddonNotRunningError,
AddonConfigurationError,
AddonNotSupportedError,
AddonNotSupportedWriteStdinError,
AddonPortConflict,
AddonPrePostBackupCommandReturnedError,
AddonsError,
AddonsJobError,
AddonUnknownError,
BackupRestoreUnknownError,
ConfigurationFileError,
DockerBuildError,
DockerContainerPortConflict,
DockerError,
HostAppArmorError,
StoreAddonNotFoundError,
)
from ..hardware.data import Device
from ..homeassistant.const import WSEvent
@@ -93,7 +83,6 @@ from ..resolution.data import Issue
from ..store.addon import AddonStore
from ..utils import check_port
from ..utils.apparmor import adjust_profile
from ..utils.dt import utc_from_timestamp
from ..utils.json import read_json_file, write_json_file
from ..utils.sentry import async_capture_exception
from .const import (
@@ -246,7 +235,7 @@ class Addon(AddonModel):
await self.instance.check_image(self.version, default_image, self.arch)
except DockerError:
_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)
self.persist[ATTR_IMAGE] = default_image
@@ -729,16 +718,18 @@ class Addon(AddonModel):
options = self.schema.validate(self.options)
await self.sys_run_in_executor(write_json_file, self.path_options, options)
except vol.Invalid as ex:
raise AddonConfigurationInvalidError(
_LOGGER.error,
addon=self.slug,
validation_error=humanize_error(self.options, ex),
) from None
except ConfigurationFileError as err:
_LOGGER.error(
"Add-on %s has invalid options: %s",
self.slug,
humanize_error(self.options, ex),
)
except ConfigurationFileError:
_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(
name="addon_unload",
@@ -781,7 +772,7 @@ class Addon(AddonModel):
async def install(self) -> None:
"""Install and setup this addon."""
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)
@@ -802,17 +793,9 @@ class Addon(AddonModel):
await self.instance.install(
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:
_LOGGER.error("Could not pull image to update addon %s: %s", self.slug, err)
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()
@@ -836,8 +819,7 @@ class Addon(AddonModel):
try:
await self.instance.remove(remove_image=remove_image)
except DockerError as err:
_LOGGER.error("Could not remove image for addon %s: %s", self.slug, err)
raise AddonUnknownError(addon=self.slug) from err
raise AddonsError() from err
self.state = AddonState.UNKNOWN
@@ -902,7 +884,7 @@ class Addon(AddonModel):
if it was running. Else nothing is returned.
"""
if not self.addon_store:
raise StoreAddonNotFoundError(addon=self.slug)
raise AddonsError("Missing from store, cannot update!")
old_image = self.image
# Cache data to prevent races with other updates to global
@@ -910,12 +892,8 @@ class Addon(AddonModel):
try:
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:
_LOGGER.error("Could not pull image to update addon %s: %s", self.slug, err)
raise AddonUnknownError(addon=self.slug) from err
raise AddonsError() from err
# Stop the addon if running
if (last_state := self.state) in {AddonState.STARTED, AddonState.STARTUP}:
@@ -926,10 +904,6 @@ class Addon(AddonModel):
await self.sys_addons.data.update(store)
await self._check_ingress_port()
# Reload ingress tokens in case addon gained ingress support
if self.with_ingress:
await self.sys_ingress.reload()
# Cleanup
with suppress(DockerError):
await self.instance.cleanup(
@@ -961,33 +935,17 @@ class Addon(AddonModel):
"""
last_state: AddonState = self.state
try:
# remove docker container and image but not addon config
# remove docker container but not addon config
try:
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)
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:
_LOGGER.error(
"Could not pull image to update addon %s: %s", self.slug, err
)
raise AddonUnknownError(addon=self.slug) from err
raise AddonsError() from err
if self.addon_store:
await self.sys_addons.data.update(self.addon_store)
await self._check_ingress_port()
# Reload ingress tokens in case addon gained ingress support
if self.with_ingress:
await self.sys_ingress.reload()
_LOGGER.info("Add-on '%s' successfully rebuilt", self.slug)
finally:
@@ -1152,16 +1110,9 @@ class Addon(AddonModel):
self._startup_event.clear()
try:
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:
_LOGGER.error("Could not start container for addon %s: %s", self.slug, err)
self.state = AddonState.ERROR
raise AddonUnknownError(addon=self.slug) from err
raise AddonsError() from err
return self.sys_create_task(self._wait_for_startup())
@@ -1176,9 +1127,8 @@ class Addon(AddonModel):
try:
await self.instance.stop()
except DockerError as err:
_LOGGER.error("Could not stop container for addon %s: %s", self.slug, err)
self.state = AddonState.ERROR
raise AddonUnknownError(addon=self.slug) from err
raise AddonsError() from err
@Job(
name="addon_restart",
@@ -1194,6 +1144,13 @@ class Addon(AddonModel):
await self.stop()
return await self.start()
def logs(self) -> Awaitable[bytes]:
"""Return add-ons log output.
Return a coroutine.
"""
return self.instance.logs()
def is_running(self) -> Awaitable[bool]:
"""Return True if Docker container is running.
@@ -1204,15 +1161,9 @@ class Addon(AddonModel):
async def stats(self) -> DockerStats:
"""Return stats of container."""
try:
if not await self.is_running():
raise AddonNotRunningError(_LOGGER.warning, addon=self.slug)
return await self.instance.stats()
except DockerError as err:
_LOGGER.error(
"Could not get stats of container for addon %s: %s", self.slug, err
)
raise AddonUnknownError(addon=self.slug) from err
raise AddonsError() from err
@Job(
name="addon_write_stdin",
@@ -1222,35 +1173,31 @@ class Addon(AddonModel):
async def write_stdin(self, data) -> None:
"""Write data to add-on stdin."""
if not self.with_stdin:
raise AddonNotSupportedWriteStdinError(_LOGGER.error, addon=self.slug)
raise AddonNotSupportedError(
f"Add-on {self.slug} does not support writing to stdin!", _LOGGER.error
)
try:
if not await self.is_running():
raise AddonNotRunningError(_LOGGER.warning, addon=self.slug)
await self.instance.write_stdin(data)
return await self.instance.write_stdin(data)
except DockerError as err:
_LOGGER.error(
"Could not write stdin to container for addon %s: %s", self.slug, err
)
raise AddonUnknownError(addon=self.slug) from err
raise AddonsError() from err
async def _backup_command(self, command: str) -> None:
try:
command_return: ExecReturn = await self.instance.run_inside(command)
command_return = await self.instance.run_inside(command)
if command_return.exit_code != 0:
_LOGGER.debug(
"Pre-/Post backup command failed with: %s",
command_return.output.decode("utf-8", errors="replace"),
"Pre-/Post backup command failed with: %s", command_return.output
)
raise AddonPrePostBackupCommandReturnedError(
_LOGGER.error, addon=self.slug, exit_code=command_return.exit_code
raise AddonsError(
f"Pre-/Post backup command returned error code: {command_return.exit_code}",
_LOGGER.error,
)
except DockerError as err:
_LOGGER.error(
"Failed running pre-/post backup command %s: %s", command, err
)
raise AddonUnknownError(addon=self.slug) from err
raise AddonsError(
f"Failed running pre-/post backup command {command}: {str(err)}",
_LOGGER.error,
) from err
@Job(
name="addon_begin_backup",
@@ -1317,7 +1264,7 @@ class Addon(AddonModel):
on_condition=AddonsJobError,
concurrency=JobConcurrency.GROUP_REJECT,
)
async def backup(self, tar_file: SecureTarFile) -> asyncio.Task | None:
async def backup(self, tar_file: tarfile.TarFile) -> asyncio.Task | None:
"""Backup state of an add-on.
Returns a Task that completes when addon has state 'started' (see start)
@@ -1325,59 +1272,68 @@ class Addon(AddonModel):
"""
def _addon_backup(
store_image: bool,
metadata: dict[str, Any],
apparmor_profile: str | None,
addon_config_used: bool,
temp_dir: TemporaryDirectory,
temp_path: Path,
):
"""Start the backup process."""
# Store local configs/state
try:
write_json_file(temp_path.joinpath("addon.json"), metadata)
except ConfigurationFileError as err:
_LOGGER.error("Can't save meta for %s: %s", self.slug, err)
raise BackupRestoreUnknownError() from err
with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp:
temp_path = Path(temp)
# Store AppArmor Profile
if apparmor_profile:
profile_backup_file = temp_path.joinpath("apparmor.txt")
# store local image
if store_image:
try:
self.instance.export_image(temp_path.joinpath("image.tar"))
except DockerError as err:
raise AddonsError() from err
# Store local configs/state
try:
self.sys_host.apparmor.backup_profile(
apparmor_profile, profile_backup_file
)
except HostAppArmorError as err:
_LOGGER.error(
"Can't backup AppArmor profile for %s: %s", self.slug, err
)
raise BackupRestoreUnknownError() from err
write_json_file(temp_path.joinpath("addon.json"), metadata)
except ConfigurationFileError as err:
raise AddonsError(
f"Can't save meta for {self.slug}", _LOGGER.error
) from err
# Write tarfile
with tar_file as backup:
# Backup metadata
backup.add(temp_dir.name, arcname=".")
# Store AppArmor Profile
if apparmor_profile:
profile_backup_file = temp_path.joinpath("apparmor.txt")
try:
self.sys_host.apparmor.backup_profile(
apparmor_profile, profile_backup_file
)
except HostAppArmorError as err:
raise AddonsError(
"Can't backup AppArmor profile", _LOGGER.error
) from err
# Backup data
atomic_contents_add(
backup,
self.path_data,
file_filter=partial(
self._is_excluded_by_filter, self.path_data, "data"
),
arcname="data",
)
# Write tarfile
with tar_file as backup:
# Backup metadata
backup.add(temp, arcname=".")
# Backup config (if used and existing, restore handles this gracefully)
if addon_config_used and self.path_config.is_dir():
# Backup data
atomic_contents_add(
backup,
self.path_config,
self.path_data,
file_filter=partial(
self._is_excluded_by_filter, self.path_config, "config"
self._is_excluded_by_filter, self.path_data, "data"
),
arcname="config",
arcname="data",
)
# Backup config (if used and existing, restore handles this gracefully)
if addon_config_used and self.path_config.is_dir():
atomic_contents_add(
backup,
self.path_config,
file_filter=partial(
self._is_excluded_by_filter, self.path_config, "config"
),
arcname="config",
)
wait_for_start: asyncio.Task | None = None
data = {
@@ -1391,35 +1347,21 @@ class Addon(AddonModel):
)
was_running = await self.begin_backup()
temp_dir = await self.sys_run_in_executor(
TemporaryDirectory, dir=self.sys_config.path_tmp
)
temp_path = Path(temp_dir.name)
_LOGGER.info("Building backup for add-on %s", self.slug)
try:
# store local image
if self.need_build:
await self.instance.export_image(temp_path.joinpath("image.tar"))
_LOGGER.info("Building backup for add-on %s", self.slug)
await self.sys_run_in_executor(
partial(
_addon_backup,
store_image=self.need_build,
metadata=data,
apparmor_profile=apparmor_profile,
addon_config_used=self.addon_config_used,
temp_dir=temp_dir,
temp_path=temp_path,
)
)
_LOGGER.info("Finish backup for addon %s", self.slug)
except DockerError as err:
_LOGGER.error("Can't export image for addon %s: %s", self.slug, err)
raise BackupRestoreUnknownError() from err
except (tarfile.TarError, OSError, AddFileError) as err:
_LOGGER.error("Can't write backup tarfile for addon %s: %s", self.slug, err)
raise BackupRestoreUnknownError() from err
raise AddonsError(f"Can't write tarfile: {err}", _LOGGER.error) from err
finally:
await self.sys_run_in_executor(temp_dir.cleanup)
if was_running:
wait_for_start = await self.end_backup()
@@ -1430,7 +1372,7 @@ class Addon(AddonModel):
on_condition=AddonsJobError,
concurrency=JobConcurrency.GROUP_REJECT,
)
async def restore(self, tar_file: SecureTarFile) -> asyncio.Task | None:
async def restore(self, tar_file: tarfile.TarFile) -> asyncio.Task | None:
"""Restore state of an add-on.
Returns a Task that completes when addon has state 'started' (see start)
@@ -1460,24 +1402,28 @@ class Addon(AddonModel):
try:
tmp, data = await self.sys_run_in_executor(_extract_tarfile)
except tarfile.TarError as err:
_LOGGER.error("Can't extract backup tarfile for %s: %s", self.slug, err)
raise BackupRestoreUnknownError() from err
raise AddonsError(
f"Can't read tarfile {tar_file}: {err}", _LOGGER.error
) from err
except ConfigurationFileError as err:
raise AddonUnknownError(addon=self.slug) from err
raise AddonsError() from err
try:
# Validate
try:
data = SCHEMA_ADDON_BACKUP(data)
except vol.Invalid as err:
raise AddonBackupMetadataInvalidError(
raise AddonsError(
f"Can't validate {self.slug}, backup data: {humanize_error(data, err)}",
_LOGGER.error,
addon=self.slug,
validation_error=humanize_error(data, err),
) from err
# Validate availability. Raises if not
self._validate_availability(data[ATTR_SYSTEM], logger=_LOGGER.error)
# If available
if not self._available(data[ATTR_SYSTEM]):
raise AddonNotSupportedError(
f"Add-on {self.slug} is not available for this platform",
_LOGGER.error,
)
# Restore local add-on information
_LOGGER.info("Restore config for addon %s", self.slug)
@@ -1536,10 +1482,9 @@ class Addon(AddonModel):
try:
await self.sys_run_in_executor(_restore_data)
except shutil.Error as err:
_LOGGER.error(
"Can't restore origin data for %s: %s", self.slug, err
)
raise BackupRestoreUnknownError() from err
raise AddonsError(
f"Can't restore origin data: {err}", _LOGGER.error
) from err
# Restore AppArmor
profile_file = Path(tmp.name, "apparmor.txt")
@@ -1550,11 +1495,10 @@ class Addon(AddonModel):
)
except HostAppArmorError as err:
_LOGGER.error(
"Can't restore AppArmor profile for add-on %s: %s",
"Can't restore AppArmor profile for add-on %s",
self.slug,
err,
)
raise BackupRestoreUnknownError() from err
raise AddonsError() from err
finally:
# Is add-on loaded

View File

@@ -5,8 +5,7 @@ from __future__ import annotations
import base64
from functools import cached_property
import json
import logging
from pathlib import Path, PurePath
from pathlib import Path
from typing import TYPE_CHECKING, Any
from awesomeversion import AwesomeVersion
@@ -24,22 +23,15 @@ from ..const import (
CpuArch,
)
from ..coresys import CoreSys, CoreSysAttributes
from ..docker.const import DOCKER_HUB, DOCKER_HUB_LEGACY, DockerMount, MountType
from ..docker.const import DOCKER_HUB
from ..docker.interface import MAP_ARCH
from ..exceptions import (
AddonBuildArchitectureNotSupportedError,
AddonBuildDockerfileMissingError,
ConfigurationFileError,
HassioArchNotFound,
)
from ..exceptions import ConfigurationFileError, HassioArchNotFound
from ..utils.common import FileConfiguration, find_one_filetype
from .validate import SCHEMA_BUILD_CONFIG
if TYPE_CHECKING:
from .manager import AnyAddon
_LOGGER: logging.Logger = logging.getLogger(__name__)
class AddonBuild(FileConfiguration, CoreSysAttributes):
"""Handle build options for add-ons."""
@@ -84,7 +76,7 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
def base_image(self) -> str:
"""Return base image for this add-on."""
if not self._data[ATTR_BUILD_FROM]:
return f"ghcr.io/home-assistant/{self.sys_arch.default!s}-base:latest"
return f"ghcr.io/home-assistant/{self.sys_arch.default}-base:latest"
if isinstance(self._data[ATTR_BUILD_FROM], str):
return self._data[ATTR_BUILD_FROM]
@@ -120,7 +112,7 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
return self.addon.path_location.joinpath(f"Dockerfile.{self.arch}")
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."""
def build_is_valid() -> bool:
@@ -132,17 +124,9 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
)
try:
if not await self.sys_run_in_executor(build_is_valid):
raise AddonBuildDockerfileMissingError(
_LOGGER.error, addon=self.addon.slug
)
return await self.sys_run_in_executor(build_is_valid)
except HassioArchNotFound:
raise AddonBuildArchitectureNotSupportedError(
_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
return False
def get_docker_config_json(self) -> str | None:
"""Generate Docker config.json content with registry credentials for base image.
@@ -171,11 +155,8 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
# 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
"https://index.docker.io/v1/" if registry == DOCKER_HUB else registry
)
config = {"auths": {registry_key: {"auth": auth_string}}}
@@ -232,39 +213,25 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
self.addon.path_location
)
mounts = [
DockerMount(
type=MountType.BIND,
source=SOCKET_DOCKER.as_posix(),
target="/var/run/docker.sock",
read_only=False,
),
DockerMount(
type=MountType.BIND,
source=addon_extern_path.as_posix(),
target="/addon",
read_only=True,
),
]
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
)
mounts.append(
DockerMount(
type=MountType.BIND,
source=docker_config_extern_path.as_posix(),
target="/root/.docker/config.json",
read_only=True,
)
)
volumes[docker_config_extern_path] = {
"bind": "/root/.docker/config.json",
"mode": "ro",
}
return {
"command": build_cmd,
"mounts": mounts,
"working_dir": PurePath("/addon"),
"volumes": volumes,
"working_dir": "/addon",
}
def _fix_label(self, label_name: str) -> str:

View File

@@ -4,10 +4,10 @@ import asyncio
from collections.abc import Awaitable
from contextlib import suppress
import logging
import tarfile
from typing import Self, Union
from attr import evolve
from securetar import SecureTarFile
from ..const import AddonBoot, AddonStartup, AddonState
from ..coresys import CoreSys, CoreSysAttributes
@@ -334,7 +334,9 @@ class AddonManager(CoreSysAttributes):
],
on_condition=AddonsJobError,
)
async def restore(self, slug: str, tar_file: SecureTarFile) -> asyncio.Task | None:
async def restore(
self, slug: str, tar_file: tarfile.TarFile
) -> asyncio.Task | None:
"""Restore state of an add-on.
Returns a Task that completes when addon has state 'started' (see addon.start)

View File

@@ -11,6 +11,8 @@ from typing import Any
from awesomeversion import AwesomeVersion, AwesomeVersionException
from supervisor.utils.dt import utc_from_timestamp
from ..const import (
ATTR_ADVANCED,
ATTR_APPARMOR,
@@ -98,7 +100,6 @@ from ..exceptions import (
from ..jobs.const import JOB_GROUP_ADDON
from ..jobs.job_group import JobGroup
from ..utils import version_is_new_enough
from ..utils.dt import utc_from_timestamp
from .configuration import FolderMapping
from .const import (
ATTR_BACKUP,
@@ -315,12 +316,12 @@ class AddonModel(JobGroup, ABC):
@property
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)
@property
def panel_admin(self) -> bool:
"""Return if panel is only available for admin users."""
def panel_admin(self) -> str:
"""Return panel icon for Ingress frame."""
return self.data[ATTR_PANEL_ADMIN]
@property
@@ -488,7 +489,7 @@ class AddonModel(JobGroup, ABC):
return self.data[ATTR_DEVICETREE]
@property
def with_tmpfs(self) -> bool:
def with_tmpfs(self) -> str | None:
"""Return if tmp is in memory of add-on."""
return self.data[ATTR_TMPFS]
@@ -508,7 +509,7 @@ class AddonModel(JobGroup, ABC):
return self.data[ATTR_VIDEO]
@property
def homeassistant_version(self) -> AwesomeVersion | None:
def homeassistant_version(self) -> str | None:
"""Return min Home Assistant version they needed by Add-on."""
return self.data.get(ATTR_HOMEASSISTANT)
@@ -725,4 +726,4 @@ class AddonModel(JobGroup, ABC):
return config[ATTR_IMAGE].format(arch=arch)
# local build
return f"{config[ATTR_REPOSITORY]}/{self.sys_arch.default!s}-addon-{config[ATTR_SLUG]}"
return f"{config[ATTR_REPOSITORY]}/{self.sys_arch.default}-addon-{config[ATTR_SLUG]}"

View File

@@ -75,7 +75,7 @@ class AddonOptions(CoreSysAttributes):
"""Create a schema for add-on options."""
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."""
options = {}
@@ -169,10 +169,6 @@ class AddonOptions(CoreSysAttributes):
elif typ.startswith(_LIST):
return vol.In(match.group("list").split("|"))(str(value))
elif typ.startswith(_DEVICE):
if not isinstance(value, str):
raise vol.Invalid(
f"Expected a string for option '{key}' in {self._name} ({self._slug})"
)
try:
device = self.sys_hardware.get_by_path(Path(value))
except HardwareNotFound:
@@ -197,7 +193,9 @@ class AddonOptions(CoreSysAttributes):
f"Fatal error for option '{key}' with type '{typ}' in {self._name} ({self._slug})"
) from None
def _nested_validate_list(self, typ: Any, data_list: Any, key: str) -> list[Any]:
def _nested_validate_list(
self, typ: Any, data_list: list[Any], key: str
) -> list[Any]:
"""Validate nested items."""
options = []
@@ -215,7 +213,7 @@ class AddonOptions(CoreSysAttributes):
return options
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."""
options = {}
@@ -266,7 +264,7 @@ class UiOptions(CoreSysAttributes):
def __init__(self, coresys: CoreSys) -> None:
"""Initialize UI option render."""
self.coresys: CoreSys = coresys
self.coresys = coresys
def __call__(self, raw_schema: dict[str, Any]) -> list[dict[str, Any]]:
"""Generate UI schema."""
@@ -281,10 +279,10 @@ class UiOptions(CoreSysAttributes):
def _ui_schema_element(
self,
ui_schema: list[dict[str, Any]],
value: str | list[Any] | dict[str, Any],
value: str,
key: str,
multiple: bool = False,
) -> None:
):
if isinstance(value, list):
# nested value list
assert not multiple

View File

@@ -522,7 +522,6 @@ class RestAPI(CoreSysAttributes):
web.get("/core/api/stream", api_proxy.stream),
web.post("/core/api/{path:.+}", api_proxy.api),
web.get("/core/api/{path:.+}", api_proxy.api),
web.delete("/core/api/{path:.+}", api_proxy.api),
web.get("/core/api/", api_proxy.api),
]
)
@@ -783,10 +782,6 @@ class RestAPI(CoreSysAttributes):
web.delete(
"/store/repositories/{repository}", api_store.remove_repository
),
web.post(
"/store/repositories/{repository}/repair",
api_store.repositories_repository_repair,
),
]
)

View File

@@ -100,9 +100,6 @@ from ..const import (
from ..coresys import CoreSysAttributes
from ..docker.stats import DockerStats
from ..exceptions import (
AddonBootConfigCannotChangeError,
AddonConfigurationInvalidError,
AddonNotSupportedWriteStdinError,
APIAddonNotInstalled,
APIError,
APIForbidden,
@@ -128,7 +125,6 @@ SCHEMA_OPTIONS = vol.Schema(
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
vol.Optional(ATTR_INGRESS_PANEL): 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
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
body = await api_validate(SCHEMA_OPTIONS, request)
body = await api_validate(addon_schema, request)
if ATTR_OPTIONS in body:
# None resets options to defaults, otherwise validate the 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
addon.options = body[ATTR_OPTIONS]
if ATTR_BOOT in body:
if addon.boot_config == AddonBootConfig.MANUAL_ONLY:
raise AddonBootConfigCannotChangeError(
addon=addon.slug, boot_config=addon.boot_config.value
raise APIError(
f"Addon {addon.slug} boot option is set to {addon.boot_config} so it cannot be changed"
)
addon.boot = body[ATTR_BOOT]
if ATTR_AUTO_UPDATE in body:
@@ -394,7 +385,7 @@ class APIAddons(CoreSysAttributes):
return data
@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."""
slug: str = request.match_info["addon"]
if slug != "self":
@@ -439,11 +430,11 @@ class APIAddons(CoreSysAttributes):
}
@api_process
async def uninstall(self, request: web.Request) -> None:
async def uninstall(self, request: web.Request) -> Awaitable[None]:
"""Uninstall add-on."""
addon = self.get_addon_for_request(request)
body: dict[str, Any] = await api_validate(SCHEMA_UNINSTALL, request)
await asyncio.shield(
return await asyncio.shield(
self.sys_addons.uninstall(
addon.slug, remove_config=body[ATTR_REMOVE_CONFIG]
)
@@ -485,7 +476,7 @@ class APIAddons(CoreSysAttributes):
"""Write to stdin of add-on."""
addon = self.get_addon_for_request(request)
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()
await asyncio.shield(addon.write_stdin(data))

View File

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

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
import errno
from io import BufferedWriter
from io import IOBase
import logging
from pathlib import Path
import re
@@ -44,7 +44,6 @@ from ..const import (
ATTR_TIMEOUT,
ATTR_TYPE,
ATTR_VERSION,
DEFAULT_CHUNK_SIZE,
REQUEST_FROM,
)
from ..coresys import CoreSysAttributes
@@ -212,7 +211,7 @@ class APIBackups(CoreSysAttributes):
await self.sys_backups.save_data()
@api_process
async def reload(self, _: web.Request) -> bool:
async def reload(self, _):
"""Reload backup list."""
await asyncio.shield(self.sys_backups.reload())
return True
@@ -311,7 +310,7 @@ class APIBackups(CoreSysAttributes):
if background and not backup_task.done():
return {ATTR_JOB_ID: job_id}
backup: Backup | None = await backup_task
backup: Backup = await backup_task
if backup:
return {ATTR_JOB_ID: job_id, ATTR_SLUG: backup.slug}
raise APIError(
@@ -347,7 +346,7 @@ class APIBackups(CoreSysAttributes):
if background and not backup_task.done():
return {ATTR_JOB_ID: job_id}
backup: Backup | None = await backup_task
backup: Backup = await backup_task
if backup:
return {ATTR_JOB_ID: job_id, ATTR_SLUG: backup.slug}
raise APIError(
@@ -422,7 +421,7 @@ class APIBackups(CoreSysAttributes):
await self.sys_backups.remove(backup, locations=locations)
@api_process
async def download(self, request: web.Request) -> web.StreamResponse:
async def download(self, request: web.Request):
"""Download a backup file."""
backup = self._extract_slug(request)
# Query will give us '' for /backups, convert value to None
@@ -452,7 +451,7 @@ class APIBackups(CoreSysAttributes):
return response
@api_process
async def upload(self, request: web.Request) -> dict[str, str] | bool:
async def upload(self, request: web.Request):
"""Upload a backup file."""
location: LOCATION_TYPE = None
locations: list[LOCATION_TYPE] | None = None
@@ -481,14 +480,14 @@ class APIBackups(CoreSysAttributes):
tmp_path = await self.sys_backups.get_upload_path_for_location(location)
temp_dir: TemporaryDirectory | None = None
backup_file_stream: BufferedWriter | None = None
backup_file_stream: IOBase | None = None
def open_backup_file() -> tuple[Path, BufferedWriter]:
def open_backup_file() -> Path:
nonlocal temp_dir, backup_file_stream
temp_dir = TemporaryDirectory(dir=tmp_path.as_posix())
tar_file = Path(temp_dir.name, "upload.tar")
backup_file_stream = tar_file.open("wb")
return (tar_file, backup_file_stream)
return tar_file
def close_backup_file() -> None:
if backup_file_stream:
@@ -504,10 +503,12 @@ class APIBackups(CoreSysAttributes):
if not isinstance(contents, BodyPartReader):
raise APIError("Improperly formatted upload, could not read backup")
tar_file, backup_writer = await self.sys_run_in_executor(open_backup_file)
while chunk := await contents.read_chunk(size=DEFAULT_CHUNK_SIZE):
await self.sys_run_in_executor(backup_writer.write, chunk)
await self.sys_run_in_executor(backup_writer.close)
tar_file = await self.sys_run_in_executor(open_backup_file)
while chunk := await contents.read_chunk(size=2**16):
await self.sys_run_in_executor(
cast(IOBase, backup_file_stream).write, chunk
)
await self.sys_run_in_executor(cast(IOBase, backup_file_stream).close)
backup = await asyncio.shield(
self.sys_backups.import_backup(

View File

@@ -7,6 +7,8 @@ from aiohttp import web
from awesomeversion import AwesomeVersion
import voluptuous as vol
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
from ..const import (
ATTR_ENABLE_IPV6,
ATTR_HOSTNAME,
@@ -21,7 +23,6 @@ from ..const import (
)
from ..coresys import CoreSysAttributes
from ..exceptions import APINotFound
from ..resolution.const import ContextType, IssueType, SuggestionType
from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -45,7 +46,7 @@ SCHEMA_OPTIONS = vol.Schema(
SCHEMA_MIGRATE_DOCKER_STORAGE_DRIVER = vol.Schema(
{
vol.Required(ATTR_STORAGE_DRIVER): vol.In(["overlayfs"]),
vol.Required(ATTR_STORAGE_DRIVER): vol.In(["overlayfs", "overlay2"]),
}
)
@@ -54,7 +55,7 @@ class APIDocker(CoreSysAttributes):
"""Handle RESTful API for Docker configuration."""
@api_process
async def info(self, request: web.Request) -> dict[str, Any]:
async def info(self, request: web.Request):
"""Get docker info."""
data_registries = {}
for hostname, registry in self.sys_docker.config.registries.items():
@@ -112,7 +113,7 @@ class APIDocker(CoreSysAttributes):
return {ATTR_REGISTRIES: data_registries}
@api_process
async def create_registry(self, request: web.Request) -> None:
async def create_registry(self, request: web.Request):
"""Create a new docker registry."""
body = await api_validate(SCHEMA_DOCKER_REGISTRY, request)
@@ -122,7 +123,7 @@ class APIDocker(CoreSysAttributes):
await self.sys_docker.config.save_data()
@api_process
async def remove_registry(self, request: web.Request) -> None:
async def remove_registry(self, request: web.Request):
"""Delete a docker registry."""
hostname = request.match_info.get(ATTR_HOSTNAME)
if hostname not in self.sys_docker.config.registries:

View File

@@ -18,7 +18,6 @@ from ..const import (
ATTR_BLK_WRITE,
ATTR_BOOT,
ATTR_CPU_PERCENT,
ATTR_DUPLICATE_LOG_FILE,
ATTR_IMAGE,
ATTR_IP_ADDRESS,
ATTR_JOB_ID,
@@ -56,7 +55,6 @@ SCHEMA_OPTIONS = vol.Schema(
vol.Optional(ATTR_AUDIO_OUTPUT): vol.Maybe(str),
vol.Optional(ATTR_AUDIO_INPUT): vol.Maybe(str),
vol.Optional(ATTR_BACKUPS_EXCLUDE_DATABASE): vol.Boolean(),
vol.Optional(ATTR_DUPLICATE_LOG_FILE): vol.Boolean(),
}
)
@@ -114,7 +112,6 @@ class APIHomeAssistant(CoreSysAttributes):
ATTR_AUDIO_INPUT: self.sys_homeassistant.audio_input,
ATTR_AUDIO_OUTPUT: self.sys_homeassistant.audio_output,
ATTR_BACKUPS_EXCLUDE_DATABASE: self.sys_homeassistant.backups_exclude_database,
ATTR_DUPLICATE_LOG_FILE: self.sys_homeassistant.duplicate_log_file,
}
@api_process
@@ -154,13 +151,10 @@ class APIHomeAssistant(CoreSysAttributes):
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()
@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."""
stats = await self.sys_homeassistant.core.stats()
if not stats:
@@ -197,7 +191,7 @@ class APIHomeAssistant(CoreSysAttributes):
return await update_task
@api_process
async def stop(self, request: web.Request) -> None:
async def stop(self, request: web.Request) -> Awaitable[None]:
"""Stop Home Assistant."""
body = await api_validate(SCHEMA_STOP, request)
await self._check_offline_migration(force=body[ATTR_FORCE])

View File

@@ -1,7 +1,6 @@
"""Init file for Supervisor host RESTful API."""
import asyncio
from collections.abc import Awaitable
from contextlib import suppress
import json
import logging
@@ -100,7 +99,7 @@ class APIHost(CoreSysAttributes):
)
@api_process
async def info(self, request: web.Request) -> dict[str, Any]:
async def info(self, request):
"""Return host information."""
return {
ATTR_AGENT_VERSION: self.sys_dbus.agent.version,
@@ -129,7 +128,7 @@ class APIHost(CoreSysAttributes):
}
@api_process
async def options(self, request: web.Request) -> None:
async def options(self, request):
"""Edit host settings."""
body = await api_validate(SCHEMA_OPTIONS, request)
@@ -140,7 +139,7 @@ class APIHost(CoreSysAttributes):
)
@api_process
async def reboot(self, request: web.Request) -> None:
async def reboot(self, request):
"""Reboot host."""
body = await api_validate(SCHEMA_SHUTDOWN, request)
await self._check_ha_offline_migration(force=body[ATTR_FORCE])
@@ -148,7 +147,7 @@ class APIHost(CoreSysAttributes):
return await asyncio.shield(self.sys_host.control.reboot())
@api_process
async def shutdown(self, request: web.Request) -> None:
async def shutdown(self, request):
"""Poweroff host."""
body = await api_validate(SCHEMA_SHUTDOWN, request)
await self._check_ha_offline_migration(force=body[ATTR_FORCE])
@@ -156,12 +155,12 @@ class APIHost(CoreSysAttributes):
return await asyncio.shield(self.sys_host.control.shutdown())
@api_process
def reload(self, request: web.Request) -> Awaitable[None]:
def reload(self, request):
"""Reload host data."""
return asyncio.shield(self.sys_host.reload())
@api_process
async def services(self, request: web.Request) -> dict[str, Any]:
async def services(self, request):
"""Return list of available services."""
services = []
for unit in self.sys_host.services:
@@ -176,7 +175,7 @@ class APIHost(CoreSysAttributes):
return {ATTR_SERVICES: services}
@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."""
boot_ids = await self.sys_host.logs.get_boot_ids()
return {
@@ -187,7 +186,7 @@ class APIHost(CoreSysAttributes):
}
@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 {ATTR_IDENTIFIERS: await self.sys_host.logs.get_identifiers()}
@@ -333,7 +332,7 @@ class APIHost(CoreSysAttributes):
)
@api_process
async def disk_usage(self, request: web.Request) -> dict[str, Any]:
async def disk_usage(self, request: web.Request) -> dict:
"""Return a breakdown of storage usage for the system."""
max_depth = request.query.get(ATTR_MAX_DEPTH, 1)

View File

@@ -1,15 +1,17 @@
"""Handle security part of this API."""
from collections.abc import Awaitable, Callable
from collections.abc import Callable
import logging
import re
from typing import Final
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 awesomeversion import AwesomeVersion
from supervisor.homeassistant.const import LANDINGPAGE
from ...addons.const import RE_SLUG
from ...const import (
REQUEST_FROM,
@@ -21,7 +23,6 @@ from ...const import (
VALID_API_STATES,
)
from ...coresys import CoreSys, CoreSysAttributes
from ...homeassistant.const import LANDINGPAGE
from ...utils import version_is_new_enough
from ..utils import api_return_error, extract_supervisor_token
@@ -88,7 +89,7 @@ CORE_ONLY_PATHS: Final = re.compile(
)
# 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(
r"^(?:"
r"|/.+/info"
@@ -179,9 +180,7 @@ class SecurityMiddleware(CoreSysAttributes):
return unquoted
@middleware
async def block_bad_requests(
self, request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
) -> StreamResponse:
async def block_bad_requests(self, request: Request, handler: Callable) -> Response:
"""Process request and tblock commonly known exploit attempts."""
if FILTERS.search(self._recursive_unquote(request.path)):
_LOGGER.warning(
@@ -199,9 +198,7 @@ class SecurityMiddleware(CoreSysAttributes):
return await handler(request)
@middleware
async def system_validation(
self, request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
) -> StreamResponse:
async def system_validation(self, request: Request, handler: Callable) -> Response:
"""Check if core is ready to response."""
if self.sys_core.state not in VALID_API_STATES:
return api_return_error(
@@ -211,9 +208,7 @@ class SecurityMiddleware(CoreSysAttributes):
return await handler(request)
@middleware
async def token_validation(
self, request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
) -> StreamResponse:
async def token_validation(self, request: Request, handler: Callable) -> Response:
"""Check security access of this layer."""
request_from: CoreSysAttributes | None = None
supervisor_token = extract_supervisor_token(request)
@@ -284,9 +279,7 @@ class SecurityMiddleware(CoreSysAttributes):
raise HTTPForbidden()
@middleware
async def core_proxy(
self, request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
) -> StreamResponse:
async def core_proxy(self, request: Request, handler: Callable) -> Response:
"""Validate user from Core API proxy."""
if (
request[REQUEST_FROM] != self.sys_homeassistant

View File

@@ -36,7 +36,6 @@ from ..const import (
ATTR_PRIMARY,
ATTR_PSK,
ATTR_READY,
ATTR_ROUTE_METRIC,
ATTR_SIGNAL,
ATTR_SSID,
ATTR_SUPERVISOR_INTERNET,
@@ -69,7 +68,6 @@ _SCHEMA_IPV4_CONFIG = vol.Schema(
vol.Optional(ATTR_ADDRESS): [vol.Coerce(IPv4Interface)],
vol.Optional(ATTR_METHOD): vol.Coerce(InterfaceMethod),
vol.Optional(ATTR_GATEWAY): vol.Coerce(IPv4Address),
vol.Optional(ATTR_ROUTE_METRIC): vol.Coerce(int),
vol.Optional(ATTR_NAMESERVERS): [vol.Coerce(IPv4Address)],
}
)
@@ -81,7 +79,6 @@ _SCHEMA_IPV6_CONFIG = vol.Schema(
vol.Optional(ATTR_ADDR_GEN_MODE): vol.Coerce(InterfaceAddrGenMode),
vol.Optional(ATTR_IP6_PRIVACY): vol.Coerce(InterfaceIp6Privacy),
vol.Optional(ATTR_GATEWAY): vol.Coerce(IPv6Address),
vol.Optional(ATTR_ROUTE_METRIC): vol.Coerce(int),
vol.Optional(ATTR_NAMESERVERS): [vol.Coerce(IPv6Address)],
}
)
@@ -116,7 +113,6 @@ def ip4config_struct(config: IpConfig, setting: IpSetting) -> dict[str, Any]:
ATTR_ADDRESS: [address.with_prefixlen for address in config.address],
ATTR_NAMESERVERS: [str(address) for address in config.nameservers],
ATTR_GATEWAY: str(config.gateway) if config.gateway else None,
ATTR_ROUTE_METRIC: setting.route_metric,
ATTR_READY: config.ready,
}
@@ -130,7 +126,6 @@ def ip6config_struct(config: IpConfig, setting: Ip6Setting) -> dict[str, Any]:
ATTR_ADDRESS: [address.with_prefixlen for address in config.address],
ATTR_NAMESERVERS: [str(address) for address in config.nameservers],
ATTR_GATEWAY: str(config.gateway) if config.gateway else None,
ATTR_ROUTE_METRIC: setting.route_metric,
ATTR_READY: config.ready,
}
@@ -206,7 +201,7 @@ class APINetwork(CoreSysAttributes):
raise APINotFound(f"Interface {name} does not exist") from None
@api_process
async def info(self, _: web.Request) -> dict[str, Any]:
async def info(self, request: web.Request) -> dict[str, Any]:
"""Return network information."""
return {
ATTR_INTERFACES: [
@@ -247,7 +242,6 @@ class APINetwork(CoreSysAttributes):
method=config.get(ATTR_METHOD, InterfaceMethod.STATIC),
address=config.get(ATTR_ADDRESS, []),
gateway=config.get(ATTR_GATEWAY),
route_metric=config.get(ATTR_ROUTE_METRIC),
nameservers=config.get(ATTR_NAMESERVERS, []),
)
elif key == ATTR_IPV6:
@@ -261,7 +255,6 @@ class APINetwork(CoreSysAttributes):
),
address=config.get(ATTR_ADDRESS, []),
gateway=config.get(ATTR_GATEWAY),
route_metric=config.get(ATTR_ROUTE_METRIC),
nameservers=config.get(ATTR_NAMESERVERS, []),
)
elif key == ATTR_WIFI:
@@ -282,7 +275,7 @@ class APINetwork(CoreSysAttributes):
await asyncio.shield(self.sys_host.network.apply_changes(interface))
@api_process
def reload(self, _: web.Request) -> Awaitable[None]:
def reload(self, request: web.Request) -> Awaitable[None]:
"""Reload network data."""
return asyncio.shield(
self.sys_host.network.update(force_connectivity_check=True)
@@ -332,8 +325,7 @@ class APINetwork(CoreSysAttributes):
ipv4_setting = IpSetting(
method=body[ATTR_IPV4].get(ATTR_METHOD, InterfaceMethod.AUTO),
address=body[ATTR_IPV4].get(ATTR_ADDRESS, []),
gateway=body[ATTR_IPV4].get(ATTR_GATEWAY),
route_metric=body[ATTR_IPV4].get(ATTR_ROUTE_METRIC),
gateway=body[ATTR_IPV4].get(ATTR_GATEWAY, None),
nameservers=body[ATTR_IPV4].get(ATTR_NAMESERVERS, []),
)
@@ -348,8 +340,7 @@ class APINetwork(CoreSysAttributes):
ATTR_IP6_PRIVACY, InterfaceIp6Privacy.DEFAULT
),
address=body[ATTR_IPV6].get(ATTR_ADDRESS, []),
gateway=body[ATTR_IPV6].get(ATTR_GATEWAY),
route_metric=body[ATTR_IPV6].get(ATTR_ROUTE_METRIC),
gateway=body[ATTR_IPV6].get(ATTR_GATEWAY, None),
nameservers=body[ATTR_IPV6].get(ATTR_NAMESERVERS, []),
)

View File

@@ -13,21 +13,16 @@ from aiohttp.hdrs import AUTHORIZATION, CONTENT_TYPE
from aiohttp.http_websocket import WSMsgType
from aiohttp.web_exceptions import HTTPBadGateway, HTTPUnauthorized
from supervisor.utils.logging import AddonLoggerAdapter
from ..coresys import CoreSysAttributes
from ..exceptions import APIError, HomeAssistantAPIError, HomeAssistantAuthError
from ..utils.json import json_dumps
from ..utils.logging import AddonLoggerAdapter
_LOGGER: logging.Logger = logging.getLogger(__name__)
FORWARD_HEADERS = (
"X-Speech-Content",
"Accept",
"Last-Event-ID",
"Mcp-Session-Id",
"MCP-Protocol-Version",
)
FORWARD_HEADERS = ("X-Speech-Content",)
HEADER_HA_ACCESS = "X-Ha-Access"
# Maximum message size for websocket messages from Home Assistant.
@@ -41,38 +36,6 @@ MAX_MESSAGE_SIZE_FROM_CORE = 64 * 1024 * 1024
class APIProxy(CoreSysAttributes):
"""API Proxy for Home Assistant."""
async def _stream_client_response(
self,
request: web.Request,
client: aiohttp.ClientResponse,
*,
content_type: str,
headers_to_copy: tuple[str, ...] = (),
) -> web.StreamResponse:
"""Stream an upstream aiohttp response to the caller.
Used for event streams (e.g. Home Assistant /api/stream) and for SSE endpoints
such as MCP (text/event-stream).
"""
response = web.StreamResponse(status=client.status)
response.content_type = content_type
for header in headers_to_copy:
if header in client.headers:
response.headers[header] = client.headers[header]
response.headers["X-Accel-Buffering"] = "no"
try:
await response.prepare(request)
async for data in client.content:
await response.write(data)
except (aiohttp.ClientError, aiohttp.ClientPayloadError):
# Client disconnected or upstream closed
pass
return response
def _check_access(self, request: web.Request):
"""Check the Supervisor token."""
if AUTHORIZATION in request.headers:
@@ -133,11 +96,16 @@ class APIProxy(CoreSysAttributes):
_LOGGER.info("Home Assistant EventStream start")
async with self._api_client(request, "stream", timeout=None) as client:
response = await self._stream_client_response(
request,
client,
content_type=request.headers.get(CONTENT_TYPE, ""),
)
response = web.StreamResponse()
response.content_type = request.headers.get(CONTENT_TYPE, "")
try:
response.headers["X-Accel-Buffering"] = "no"
await response.prepare(request)
async for data in client.content:
await response.write(data)
except (aiohttp.ClientError, aiohttp.ClientPayloadError):
pass
_LOGGER.info("Home Assistant EventStream close")
return response
@@ -151,31 +119,10 @@ class APIProxy(CoreSysAttributes):
# Normal request
path = request.match_info.get("path", "")
async with self._api_client(request, path) as client:
# Check if this is a streaming response (e.g., MCP SSE endpoints)
if client.content_type == "text/event-stream":
return await self._stream_client_response(
request,
client,
content_type=client.content_type,
headers_to_copy=(
"Cache-Control",
"Mcp-Session-Id",
),
)
# Non-streaming response
data = await client.read()
response = web.Response(
return web.Response(
body=data, status=client.status, content_type=client.content_type
)
# Copy selected headers from the upstream response
for header in (
"Cache-Control",
"Mcp-Session-Id",
):
if header in client.headers:
response.headers[header] = client.headers[header]
return response
async def _websocket_client(self) -> ClientWebSocketResponse:
"""Initialize a WebSocket API connection."""

View File

@@ -5,9 +5,10 @@ from typing import Any
from aiohttp import web
import voluptuous as vol
from supervisor.exceptions import APIGone
from ..const import ATTR_FORCE_SECURITY, ATTR_PWNED
from ..coresys import CoreSysAttributes
from ..exceptions import APIGone
from .utils import api_process, api_validate
# pylint: disable=no-value-for-parameter

View File

@@ -1,9 +1,5 @@
"""Init file for Supervisor network RESTful API."""
from typing import Any
from aiohttp import web
from ..const import (
ATTR_AVAILABLE,
ATTR_PROVIDERS,
@@ -29,7 +25,7 @@ class APIServices(CoreSysAttributes):
return service
@api_process
async def list_services(self, request: web.Request) -> dict[str, Any]:
async def list_services(self, request):
"""Show register services."""
services = []
for service in self.sys_services.list_services:
@@ -44,7 +40,7 @@ class APIServices(CoreSysAttributes):
return {ATTR_SERVICES: services}
@api_process
async def set_service(self, request: web.Request) -> None:
async def set_service(self, request):
"""Write data into a service."""
service = self._extract_service(request)
body = await api_validate(service.schema, request)
@@ -54,7 +50,7 @@ class APIServices(CoreSysAttributes):
await service.set_service_data(addon, body)
@api_process
async def get_service(self, request: web.Request) -> dict[str, Any]:
async def get_service(self, request):
"""Read data into a service."""
service = self._extract_service(request)
@@ -66,7 +62,7 @@ class APIServices(CoreSysAttributes):
return service.get_service_data()
@api_process
async def del_service(self, request: web.Request) -> None:
async def del_service(self, request):
"""Delete data into a service."""
service = self._extract_service(request)
addon = request[REQUEST_FROM]

View File

@@ -53,8 +53,7 @@ from ..const import (
REQUEST_FROM,
)
from ..coresys import CoreSysAttributes
from ..exceptions import APIError, APIForbidden, APINotFound, StoreAddonNotFoundError
from ..resolution.const import ContextType, SuggestionType
from ..exceptions import APIError, APIForbidden, APINotFound
from ..store.addon import AddonStore
from ..store.repository import Repository
from ..store.validate import validate_repository
@@ -105,7 +104,7 @@ class APIStore(CoreSysAttributes):
addon_slug: str = request.match_info["addon"]
if not (addon := self.sys_addons.get(addon_slug)):
raise StoreAddonNotFoundError(addon=addon_slug)
raise APINotFound(f"Addon {addon_slug} does not exist")
if installed and not addon.is_installed:
raise APIError(f"Addon {addon_slug} is not installed")
@@ -113,7 +112,7 @@ class APIStore(CoreSysAttributes):
if not installed and addon.is_installed:
addon = cast(Addon, addon)
if not addon.addon_store:
raise StoreAddonNotFoundError(addon=addon_slug)
raise APINotFound(f"Addon {addon_slug} does not exist in the store")
return addon.addon_store
return addon
@@ -350,30 +349,13 @@ class APIStore(CoreSysAttributes):
return self._generate_repository_information(repository)
@api_process
async def add_repository(self, request: web.Request) -> None:
async def add_repository(self, request: web.Request):
"""Add repository to the store."""
body = await api_validate(SCHEMA_ADD_REPOSITORY, request)
await asyncio.shield(self.sys_store.add_repository(body[ATTR_REPOSITORY]))
@api_process
async def remove_repository(self, request: web.Request) -> None:
async def remove_repository(self, request: web.Request):
"""Remove repository from the store."""
repository: Repository = self._extract_repository(request)
await asyncio.shield(self.sys_store.remove_repository(repository))
@api_process
async def repositories_repository_repair(self, request: web.Request) -> None:
"""Repair repository."""
repository: Repository = self._extract_repository(request)
await asyncio.shield(repository.reset())
# If we have an execute reset suggestion on this repository, dismiss it and the issue
for suggestion in self.sys_resolution.suggestions:
if (
suggestion.type == SuggestionType.EXECUTE_RESET
and suggestion.context == ContextType.STORE
and suggestion.reference == repository.slug
):
for issue in self.sys_resolution.issues_for_suggestion(suggestion):
self.sys_resolution.dismiss_issue(issue)
return

View File

@@ -80,7 +80,7 @@ class APISupervisor(CoreSysAttributes):
"""Handle RESTful API for Supervisor functions."""
@api_process
async def ping(self, request: web.Request) -> bool:
async def ping(self, request):
"""Return ok for signal that the API is ready."""
return True
@@ -248,7 +248,6 @@ class APISupervisor(CoreSysAttributes):
return asyncio.shield(self.sys_supervisor.restart())
@api_process_raw(CONTENT_TYPE_TEXT, error_type=CONTENT_TYPE_TEXT)
async def logs(self, request: web.Request) -> bytes:
def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return supervisor Docker logs."""
logs = await self.sys_supervisor.logs()
return "\n".join(logs).encode(errors="replace")
return self.sys_supervisor.logs()

View File

@@ -1,7 +1,7 @@
"""Init file for Supervisor util for RESTful API."""
import asyncio
from collections.abc import Callable, Mapping
from collections.abc import Callable
import json
from typing import Any, cast
@@ -26,7 +26,7 @@ from ..const import (
RESULT_OK,
)
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import APIError, DockerAPIError, HassioError
from ..exceptions import APIError, BackupFileNotFoundError, DockerAPIError, HassioError
from ..jobs import JobSchedulerOptions, SupervisorJob
from ..utils import check_exception_chain, get_message_from_exception_chain
from ..utils.json import json_dumps, json_loads as json_loads_util
@@ -67,10 +67,10 @@ def api_process(method):
"""Return API information."""
try:
answer = await method(*args, **kwargs)
except BackupFileNotFoundError as err:
return api_return_error(err, status=404)
except APIError as err:
return api_return_error(
err, status=err.status, job_id=err.job_id, headers=err.headers
)
return api_return_error(err, status=err.status, job_id=err.job_id)
except HassioError as err:
return api_return_error(err)
@@ -139,7 +139,6 @@ def api_return_error(
error_type: str | None = None,
status: int = 400,
*,
headers: Mapping[str, str] | None = None,
job_id: str | None = None,
) -> web.Response:
"""Return an API error message."""
@@ -152,15 +151,10 @@ def api_return_error(
match error_type:
case const.CONTENT_TYPE_TEXT:
return web.Response(
body=message, content_type=error_type, status=status, headers=headers
)
return web.Response(body=message, content_type=error_type, status=status)
case const.CONTENT_TYPE_BINARY:
return web.Response(
body=message.encode(),
content_type=error_type,
status=status,
headers=headers,
body=message.encode(), content_type=error_type, status=status
)
case _:
result: dict[str, Any] = {
@@ -178,7 +172,6 @@ def api_return_error(
result,
status=status,
dumps=json_dumps,
headers=headers,
)

View File

@@ -85,7 +85,7 @@ class CpuArchManager(CoreSysAttributes):
"""Return best match for this CPU/Platform."""
for self_arch in self.supported:
if self_arch in arch_list:
return CpuArch(self_arch)
return self_arch
raise HassioArchNotFound()
def detect_cpu(self) -> CpuArch:

View File

@@ -9,10 +9,8 @@ from .addons.addon import Addon
from .const import ATTR_PASSWORD, ATTR_TYPE, ATTR_USERNAME, FILE_HASSIO_AUTH
from .coresys import CoreSys, CoreSysAttributes
from .exceptions import (
AuthHomeAssistantAPIValidationError,
AuthInvalidNonStringValueError,
AuthError,
AuthListUsersError,
AuthListUsersNoneResponseError,
AuthPasswordResetError,
HomeAssistantAPIError,
HomeAssistantWSError,
@@ -85,8 +83,10 @@ class Auth(FileConfiguration, CoreSysAttributes):
self, addon: Addon, username: str | None, password: str | None
) -> bool:
"""Check username login."""
if username is None or password is None:
raise AuthInvalidNonStringValueError(_LOGGER.error)
if password is None:
raise AuthError("None as password is not supported!", _LOGGER.error)
if username is None:
raise AuthError("None as username is not supported!", _LOGGER.error)
_LOGGER.info("Auth request from '%s' for '%s'", addon.slug, username)
@@ -137,7 +137,7 @@ class Auth(FileConfiguration, CoreSysAttributes):
finally:
self._running.pop(username, None)
raise AuthHomeAssistantAPIValidationError()
raise AuthError()
async def change_password(self, username: str, password: str) -> None:
"""Change user password login."""
@@ -155,7 +155,7 @@ class Auth(FileConfiguration, CoreSysAttributes):
except HomeAssistantAPIError as err:
_LOGGER.error("Can't request password reset on Home Assistant: %s", err)
raise AuthPasswordResetError(user=username)
raise AuthPasswordResetError()
async def list_users(self) -> list[dict[str, Any]]:
"""List users on the Home Assistant instance."""
@@ -166,12 +166,15 @@ class Auth(FileConfiguration, CoreSysAttributes):
{ATTR_TYPE: "config/auth/list"}
)
except HomeAssistantWSError as err:
_LOGGER.error("Can't request listing users on Home Assistant: %s", err)
raise AuthListUsersError() from err
raise AuthListUsersError(
f"Can't request listing users on Home Assistant: {err}", _LOGGER.error
) from err
if users is not None:
return users
raise AuthListUsersNoneResponseError(_LOGGER.error)
raise AuthListUsersError(
"Can't request listing users on Home Assistant!", _LOGGER.error
)
@staticmethod
def _rehash(value: str, salt2: str = "") -> str:

View File

@@ -60,6 +60,7 @@ from ..utils.dt import parse_datetime, utcnow
from ..utils.json import json_bytes
from ..utils.sentinel import DEFAULT
from .const import BUF_SIZE, LOCATION_CLOUD_BACKUP, BackupType
from .utils import password_to_key
from .validate import SCHEMA_BACKUP
IGNORED_COMPARISON_FIELDS = {ATTR_PROTECTED, ATTR_CRYPTO, ATTR_DOCKER}
@@ -100,7 +101,7 @@ class Backup(JobGroup):
self._data: dict[str, Any] = data or {ATTR_SLUG: slug}
self._tmp: TemporaryDirectory | None = None
self._outer_secure_tarfile: SecureTarFile | None = None
self._password: str | None = None
self._key: bytes | None = None
self._locations: dict[str | None, BackupLocation] = {
location: BackupLocation(
path=tar_file,
@@ -170,21 +171,21 @@ class Backup(JobGroup):
self._data[ATTR_REPOSITORIES] = value
@property
def homeassistant_version(self) -> AwesomeVersion | None:
def homeassistant_version(self) -> AwesomeVersion:
"""Return backup Home Assistant version."""
if self.homeassistant is None:
return None
return self.homeassistant[ATTR_VERSION]
@property
def homeassistant_exclude_database(self) -> bool | None:
def homeassistant_exclude_database(self) -> bool:
"""Return whether database was excluded from Home Assistant backup."""
if self.homeassistant is None:
return None
return self.homeassistant[ATTR_EXCLUDE_DATABASE]
@property
def homeassistant(self) -> dict[str, Any] | None:
def homeassistant(self) -> dict[str, Any]:
"""Return backup Home Assistant data."""
return self._data[ATTR_HOMEASSISTANT]
@@ -326,7 +327,7 @@ class Backup(JobGroup):
# Set password
if password:
self._password = password
self._init_password(password)
self._data[ATTR_PROTECTED] = True
self._data[ATTR_CRYPTO] = CRYPTO_AES128
self._locations[self.location].protected = True
@@ -336,7 +337,14 @@ class Backup(JobGroup):
def set_password(self, password: str | None) -> None:
"""Set the password for an existing backup."""
self._password = password
if password:
self._init_password(password)
else:
self._key = None
def _init_password(self, password: str) -> None:
"""Create key from password."""
self._key = password_to_key(password)
async def validate_backup(self, location: str | None) -> None:
"""Validate backup.
@@ -366,9 +374,9 @@ class Backup(JobGroup):
with SecureTarFile(
ending, # Not used
gzip=self.compressed,
key=self._key,
mode="r",
fileobj=test_tar_file,
password=self._password,
):
# If we can read the tar file, the password is correct
return
@@ -584,7 +592,7 @@ class Backup(JobGroup):
addon_file = self._outer_secure_tarfile.create_inner_tar(
f"./{tar_name}",
gzip=self.compressed,
password=self._password,
key=self._key,
)
# Take backup
try:
@@ -620,6 +628,9 @@ class Backup(JobGroup):
if start_task := await self._addon_save(addon):
start_tasks.append(start_task)
except BackupError as err:
err = BackupError(
f"Can't backup add-on {addon.slug}: {str(err)}", _LOGGER.error
)
self.sys_jobs.current.capture_error(err)
return start_tasks
@@ -635,9 +646,9 @@ class Backup(JobGroup):
addon_file = SecureTarFile(
Path(self._tmp.name, tar_name),
"r",
key=self._key,
gzip=self.compressed,
bufsize=BUF_SIZE,
password=self._password,
)
# If exists inside backup
@@ -733,7 +744,7 @@ class Backup(JobGroup):
with outer_secure_tarfile.create_inner_tar(
f"./{tar_name}",
gzip=self.compressed,
password=self._password,
key=self._key,
) as tar_file:
atomic_contents_add(
tar_file,
@@ -794,9 +805,9 @@ class Backup(JobGroup):
with SecureTarFile(
tar_name,
"r",
key=self._key,
gzip=self.compressed,
bufsize=BUF_SIZE,
password=self._password,
) as tar_file:
tar_file.extractall(
path=origin_dir, members=tar_file, filter="fully_trusted"
@@ -857,13 +868,13 @@ class Backup(JobGroup):
homeassistant_file = self._outer_secure_tarfile.create_inner_tar(
f"./{tar_name}",
gzip=self.compressed,
password=self._password,
key=self._key,
)
await self.sys_homeassistant.backup(homeassistant_file, exclude_database)
# Store size
self._data[ATTR_HOMEASSISTANT][ATTR_SIZE] = await self.sys_run_in_executor(
self.homeassistant[ATTR_SIZE] = await self.sys_run_in_executor(
getattr, homeassistant_file, "size"
)
@@ -880,11 +891,7 @@ class Backup(JobGroup):
self._tmp.name, f"homeassistant.tar{'.gz' if self.compressed else ''}"
)
homeassistant_file = SecureTarFile(
tar_name,
"r",
gzip=self.compressed,
bufsize=BUF_SIZE,
password=self._password,
tar_name, "r", key=self._key, gzip=self.compressed, bufsize=BUF_SIZE
)
await self.sys_homeassistant.restore(

View File

@@ -6,6 +6,21 @@ import re
RE_DIGITS = re.compile(r"\d+")
def password_to_key(password: str) -> bytes:
"""Generate a AES Key from password."""
key: bytes = password.encode()
for _ in range(100):
key = hashlib.sha256(key).digest()
return key[:16]
def key_to_iv(key: bytes) -> bytes:
"""Generate an iv from Key."""
for _ in range(100):
key = hashlib.sha256(key).digest()
return key[:16]
def create_slug(name: str, date_str: str) -> str:
"""Generate a hash from repository."""
key = f"{date_str} - {name}".lower().encode()

View File

@@ -179,7 +179,6 @@ ATTR_DOCKER = "docker"
ATTR_DOCKER_API = "docker_api"
ATTR_DOCUMENTATION = "documentation"
ATTR_DOMAINS = "domains"
ATTR_DUPLICATE_LOG_FILE = "duplicate_log_file"
ATTR_ENABLE = "enable"
ATTR_ENABLE_IPV6 = "enable_ipv6"
ATTR_ENABLED = "enabled"
@@ -305,7 +304,6 @@ ATTR_REGISTRIES = "registries"
ATTR_REGISTRY = "registry"
ATTR_REPOSITORIES = "repositories"
ATTR_REPOSITORY = "repository"
ATTR_ROUTE_METRIC = "route_metric"
ATTR_SCHEMA = "schema"
ATTR_SECURITY = "security"
ATTR_SERIAL = "serial"
@@ -412,11 +410,6 @@ ROLE_ADMIN = "admin"
ROLE_ALL = [ROLE_DEFAULT, ROLE_HOMEASSISTANT, ROLE_BACKUP, ROLE_MANAGER, ROLE_ADMIN]
OBSERVER_PORT = 4357
# Used for stream operations
DEFAULT_CHUNK_SIZE = 2**16 # 64KiB
class AddonBootConfig(StrEnum):
"""Boot mode config for the add-on."""

View File

@@ -434,7 +434,7 @@ class Core(CoreSysAttributes):
async def repair(self) -> None:
"""Repair system integrity."""
_LOGGER.info("Starting repair of Supervisor Environment")
await self.sys_docker.repair()
await self.sys_run_in_executor(self.sys_docker.repair)
# Fix plugins
await self.sys_plugins.repair()

View File

@@ -628,17 +628,9 @@ class CoreSys:
context = callback(context)
return context
def create_task(
self, coroutine: Coroutine, *, eager_start: bool | None = None
) -> asyncio.Task:
def create_task(self, coroutine: Coroutine) -> asyncio.Task:
"""Create an async task."""
# eager_start kwarg works but wasn't added for mypy visibility until 3.14
# can remove the type ignore then
return self.loop.create_task(
coroutine,
context=self._create_context(),
eager_start=eager_start, # type: ignore
)
return self.loop.create_task(coroutine, context=self._create_context())
def call_later(
self,
@@ -855,11 +847,9 @@ class CoreSysAttributes:
"""Add a job to the executor pool."""
return self.coresys.run_in_executor(funct, *args, **kwargs)
def sys_create_task(
self, coroutine: Coroutine, *, eager_start: bool | None = None
) -> asyncio.Task:
def sys_create_task(self, coroutine: Coroutine) -> asyncio.Task:
"""Create an async task."""
return self.coresys.create_task(coroutine, eager_start=eager_start)
return self.coresys.create_task(coroutine)
def sys_call_later(
self,

View File

@@ -3,17 +3,10 @@
"bluetoothd",
"bthelper",
"btuart",
"containerd",
"dbus-broker",
"dbus-broker-launch",
"docker",
"docker-prepare",
"dockerd",
"dropbear",
"fstrim",
"haos-data-disk-detach",
"haos-swapfile",
"haos-wipe",
"hassos-apparmor",
"hassos-config",
"hassos-expand",
@@ -24,10 +17,7 @@
"kernel",
"mount",
"os-agent",
"qemu-ga",
"rauc",
"raucdb-update",
"sm-notify",
"systemd",
"systemd-coredump",
"systemd-fsck",

View File

@@ -2,7 +2,8 @@
from typing import Any
from ...utils import dbus_connected
from supervisor.dbus.utils import dbus_connected
from .const import BOARD_NAME_SUPERVISED
from .interface import BoardProxy

View File

@@ -3,8 +3,6 @@
from enum import IntEnum, StrEnum
from socket import AF_INET, AF_INET6
from .enum import DBusIntEnum, DBusStrEnum
DBUS_NAME_HAOS = "io.hass.os"
DBUS_NAME_HOSTNAME = "org.freedesktop.hostname1"
DBUS_NAME_LOGIND = "org.freedesktop.login1"
@@ -210,7 +208,7 @@ DBUS_ATTR_WWN = "WWN"
DBUS_ERR_SYSTEMD_NO_SUCH_UNIT = "org.freedesktop.systemd1.NoSuchUnit"
class RaucState(DBusStrEnum):
class RaucState(StrEnum):
"""Rauc slot states."""
GOOD = "good"
@@ -218,7 +216,7 @@ class RaucState(DBusStrEnum):
ACTIVE = "active"
class InterfaceMethod(DBusStrEnum):
class InterfaceMethod(StrEnum):
"""Interface method simple."""
AUTO = "auto"
@@ -227,7 +225,7 @@ class InterfaceMethod(DBusStrEnum):
LINK_LOCAL = "link-local"
class InterfaceAddrGenMode(DBusIntEnum):
class InterfaceAddrGenMode(IntEnum):
"""Interface addr_gen_mode."""
EUI64 = 0
@@ -236,7 +234,7 @@ class InterfaceAddrGenMode(DBusIntEnum):
DEFAULT = 3
class InterfaceIp6Privacy(DBusIntEnum):
class InterfaceIp6Privacy(IntEnum):
"""Interface ip6_privacy."""
DEFAULT = -1
@@ -245,14 +243,14 @@ class InterfaceIp6Privacy(DBusIntEnum):
ENABLED = 2
class ConnectionType(DBusStrEnum):
class ConnectionType(StrEnum):
"""Connection type."""
ETHERNET = "802-3-ethernet"
WIRELESS = "802-11-wireless"
class ConnectionState(DBusIntEnum):
class ConnectionStateType(IntEnum):
"""Connection states.
https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMActiveConnectionState
@@ -282,7 +280,7 @@ class ConnectionStateFlags(IntEnum):
EXTERNAL = 0x80
class ConnectivityState(DBusIntEnum):
class ConnectivityState(IntEnum):
"""Network connectvity.
https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMConnectivityState
@@ -295,7 +293,7 @@ class ConnectivityState(DBusIntEnum):
CONNECTIVITY_FULL = 4
class DeviceType(DBusIntEnum):
class DeviceType(IntEnum):
"""Device types.
https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMDeviceType
@@ -312,7 +310,7 @@ class DeviceType(DBusIntEnum):
LOOPBACK = 32
class WirelessMethodType(DBusIntEnum):
class WirelessMethodType(IntEnum):
"""Device Type."""
UNKNOWN = 0
@@ -329,7 +327,7 @@ class DNSAddressFamily(IntEnum):
INET6 = AF_INET6
class MulticastProtocolEnabled(DBusStrEnum):
class MulticastProtocolEnabled(StrEnum):
"""Multicast protocol enabled or resolve."""
YES = "yes"
@@ -337,7 +335,7 @@ class MulticastProtocolEnabled(DBusStrEnum):
RESOLVE = "resolve"
class MulticastDnsValue(DBusIntEnum):
class MulticastDnsValue(IntEnum):
"""Connection MulticastDNS (mdns/llmnr) values."""
DEFAULT = -1
@@ -346,7 +344,7 @@ class MulticastDnsValue(DBusIntEnum):
ANNOUNCE = 2
class DNSOverTLSEnabled(DBusStrEnum):
class DNSOverTLSEnabled(StrEnum):
"""DNS over TLS enabled."""
YES = "yes"
@@ -354,7 +352,7 @@ class DNSOverTLSEnabled(DBusStrEnum):
OPPORTUNISTIC = "opportunistic"
class DNSSECValidation(DBusStrEnum):
class DNSSECValidation(StrEnum):
"""DNSSEC validation enforced."""
YES = "yes"
@@ -362,7 +360,7 @@ class DNSSECValidation(DBusStrEnum):
ALLOW_DOWNGRADE = "allow-downgrade"
class DNSStubListenerEnabled(DBusStrEnum):
class DNSStubListenerEnabled(StrEnum):
"""DNS stub listener enabled."""
YES = "yes"
@@ -371,7 +369,7 @@ class DNSStubListenerEnabled(DBusStrEnum):
UDP_ONLY = "udp"
class ResolvConfMode(DBusStrEnum):
class ResolvConfMode(StrEnum):
"""Resolv.conf management mode."""
FOREIGN = "foreign"
@@ -400,7 +398,7 @@ class StartUnitMode(StrEnum):
ISOLATE = "isolate"
class UnitActiveState(DBusStrEnum):
class UnitActiveState(StrEnum):
"""Active state of a systemd unit."""
ACTIVE = "active"

View File

@@ -1,56 +0,0 @@
"""D-Bus tolerant enum base classes.
D-Bus services (systemd, NetworkManager, RAUC, UDisks2) can introduce new enum
values at any time via OS updates. Standard enum construction raises ValueError
for unknown values. These base classes use Python's _missing_ hook to create
pseudo-members for unknown values, preventing crashes while preserving the
original value for logging and debugging.
"""
from enum import IntEnum, StrEnum
import logging
from ..utils.sentry import fire_and_forget_capture_message
_LOGGER: logging.Logger = logging.getLogger(__name__)
_reported: set[tuple[str, str | int]] = set()
def _report_unknown_value(cls: type, value: str | int) -> None:
"""Log and report an unknown D-Bus enum value to Sentry."""
msg = f"Unknown {cls.__name__} value received from D-Bus: {value}"
_LOGGER.warning(msg)
key = (cls.__name__, value)
if key not in _reported:
_reported.add(key)
fire_and_forget_capture_message(msg)
class DBusStrEnum(StrEnum):
"""StrEnum that tolerates unknown values from D-Bus."""
@classmethod
def _missing_(cls, value: object) -> "DBusStrEnum | None":
if not isinstance(value, str):
return None
_report_unknown_value(cls, value)
obj = str.__new__(cls, value)
obj._name_ = value
obj._value_ = value
return obj
class DBusIntEnum(IntEnum):
"""IntEnum that tolerates unknown values from D-Bus."""
@classmethod
def _missing_(cls, value: object) -> "DBusIntEnum | None":
if not isinstance(value, int):
return None
_report_unknown_value(cls, value)
obj = int.__new__(cls, value)
obj._name_ = f"UNKNOWN_{value}"
obj._value_ = value
return obj

View File

@@ -7,7 +7,8 @@ from typing import Any
from dbus_fast.aio.message_bus import MessageBus
from ..exceptions import DBusInterfaceError, DBusNotConnectedError
from supervisor.exceptions import DBusInterfaceError, DBusNotConnectedError
from ..utils.dbus import DBus
from .utils import dbus_connected

View File

@@ -77,7 +77,6 @@ class IpProperties(ABC):
method: str | None
address_data: list[IpAddress] | None
gateway: str | None
route_metric: int | None
@dataclass(slots=True)
@@ -91,8 +90,8 @@ class Ip4Properties(IpProperties):
class Ip6Properties(IpProperties):
"""IPv6 properties object for Network Manager."""
addr_gen_mode: int | None
ip6_privacy: int | None
addr_gen_mode: int
ip6_privacy: int
dns: list[bytes] | None

View File

@@ -2,6 +2,8 @@
from typing import Any
from supervisor.dbus.network.setting import NetworkSetting
from ..const import (
DBUS_ATTR_CONNECTION,
DBUS_ATTR_ID,
@@ -14,13 +16,12 @@ from ..const import (
DBUS_IFACE_CONNECTION_ACTIVE,
DBUS_NAME_NM,
DBUS_OBJECT_BASE,
ConnectionState,
ConnectionStateFlags,
ConnectionStateType,
)
from ..interface import DBusInterfaceProxy, dbus_property
from ..utils import dbus_connected
from .ip_configuration import IpConfiguration
from .setting import NetworkSetting
class NetworkConnection(DBusInterfaceProxy):
@@ -66,9 +67,9 @@ class NetworkConnection(DBusInterfaceProxy):
@property
@dbus_property
def state(self) -> ConnectionState:
def state(self) -> ConnectionStateType:
"""Return the state of the connection."""
return ConnectionState(self.properties[DBUS_ATTR_STATE])
return ConnectionStateType(self.properties[DBUS_ATTR_STATE])
@property
def state_flags(self) -> set[ConnectionStateFlags]:

View File

@@ -60,7 +60,15 @@ class NetworkInterface(DBusInterfaceProxy):
@dbus_property
def type(self) -> DeviceType:
"""Return interface type."""
return DeviceType(self.properties[DBUS_ATTR_DEVICE_TYPE])
try:
return DeviceType(self.properties[DBUS_ATTR_DEVICE_TYPE])
except ValueError:
_LOGGER.debug(
"Unknown device type %s for %s, treating as UNKNOWN",
self.properties[DBUS_ATTR_DEVICE_TYPE],
self.object_path,
)
return DeviceType.UNKNOWN
@property
@dbus_property

View File

@@ -46,15 +46,6 @@ class IpConfiguration(DBusInterfaceProxy):
"""Primary interface of object to get property values from."""
return self._properties_interface
@property
@dbus_property
def address(self) -> list[IPv4Interface | IPv6Interface]:
"""Get address."""
return [
ip_interface(f"{address[ATTR_ADDRESS]}/{address[ATTR_PREFIX]}")
for address in self.properties[DBUS_ATTR_ADDRESS_DATA]
]
@property
@dbus_property
def gateway(self) -> IPv4Address | IPv6Address | None:
@@ -79,3 +70,12 @@ class IpConfiguration(DBusInterfaceProxy):
ip_address(bytes(nameserver))
for nameserver in self.properties[DBUS_ATTR_NAMESERVERS]
]
@property
@dbus_property
def address(self) -> list[IPv4Interface | IPv6Interface]:
"""Get address."""
return [
ip_interface(f"{address[ATTR_ADDRESS]}/{address[ATTR_PREFIX]}")
for address in self.properties[DBUS_ATTR_ADDRESS_DATA]
]

View File

@@ -53,25 +53,27 @@ CONF_ATTR_802_WIRELESS_SECURITY_AUTH_ALG = "auth-alg"
CONF_ATTR_802_WIRELESS_SECURITY_KEY_MGMT = "key-mgmt"
CONF_ATTR_802_WIRELESS_SECURITY_PSK = "psk"
CONF_ATTR_IP_METHOD = "method"
CONF_ATTR_IP_ADDRESS_DATA = "address-data"
CONF_ATTR_IP_GATEWAY = "gateway"
CONF_ATTR_IP_ROUTE_METRIC = "route-metric"
CONF_ATTR_IP_DNS = "dns"
CONF_ATTR_IPV4_METHOD = "method"
CONF_ATTR_IPV4_ADDRESS_DATA = "address-data"
CONF_ATTR_IPV4_GATEWAY = "gateway"
CONF_ATTR_IPV4_DNS = "dns"
CONF_ATTR_IPV6_METHOD = "method"
CONF_ATTR_IPV6_ADDR_GEN_MODE = "addr-gen-mode"
CONF_ATTR_IPV6_PRIVACY = "ip6-privacy"
CONF_ATTR_IPV6_ADDRESS_DATA = "address-data"
CONF_ATTR_IPV6_GATEWAY = "gateway"
CONF_ATTR_IPV6_DNS = "dns"
_IP_IGNORE_FIELDS = [
CONF_ATTR_IP_METHOD,
CONF_ATTR_IP_ADDRESS_DATA,
CONF_ATTR_IP_GATEWAY,
CONF_ATTR_IP_ROUTE_METRIC,
CONF_ATTR_IP_DNS,
CONF_ATTR_IPV6_ADDR_GEN_MODE,
CONF_ATTR_IPV6_PRIVACY,
IPV4_6_IGNORE_FIELDS = [
"addresses",
"address-data",
"dns",
"dns-data",
"gateway",
"method",
"addr-gen-mode",
"ip6-privacy",
]
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -193,13 +195,13 @@ class NetworkSetting(DBusInterface):
new_settings,
settings,
CONF_ATTR_IPV4,
ignore_current_value=_IP_IGNORE_FIELDS,
ignore_current_value=IPV4_6_IGNORE_FIELDS,
)
_merge_settings_attribute(
new_settings,
settings,
CONF_ATTR_IPV6,
ignore_current_value=_IP_IGNORE_FIELDS,
ignore_current_value=IPV4_6_IGNORE_FIELDS,
)
_merge_settings_attribute(new_settings, settings, CONF_ATTR_MATCH)
@@ -289,28 +291,26 @@ class NetworkSetting(DBusInterface):
if CONF_ATTR_IPV4 in data:
address_data = None
if ips := data[CONF_ATTR_IPV4].get(CONF_ATTR_IP_ADDRESS_DATA):
if ips := data[CONF_ATTR_IPV4].get(CONF_ATTR_IPV4_ADDRESS_DATA):
address_data = [IpAddress(ip["address"], ip["prefix"]) for ip in ips]
self._ipv4 = Ip4Properties(
method=data[CONF_ATTR_IPV4].get(CONF_ATTR_IP_METHOD),
method=data[CONF_ATTR_IPV4].get(CONF_ATTR_IPV4_METHOD),
address_data=address_data,
gateway=data[CONF_ATTR_IPV4].get(CONF_ATTR_IP_GATEWAY),
route_metric=data[CONF_ATTR_IPV4].get(CONF_ATTR_IP_ROUTE_METRIC),
dns=data[CONF_ATTR_IPV4].get(CONF_ATTR_IP_DNS),
gateway=data[CONF_ATTR_IPV4].get(CONF_ATTR_IPV4_GATEWAY),
dns=data[CONF_ATTR_IPV4].get(CONF_ATTR_IPV4_DNS),
)
if CONF_ATTR_IPV6 in data:
address_data = None
if ips := data[CONF_ATTR_IPV6].get(CONF_ATTR_IP_ADDRESS_DATA):
if ips := data[CONF_ATTR_IPV6].get(CONF_ATTR_IPV6_ADDRESS_DATA):
address_data = [IpAddress(ip["address"], ip["prefix"]) for ip in ips]
self._ipv6 = Ip6Properties(
method=data[CONF_ATTR_IPV6].get(CONF_ATTR_IP_METHOD),
method=data[CONF_ATTR_IPV6].get(CONF_ATTR_IPV6_METHOD),
addr_gen_mode=data[CONF_ATTR_IPV6].get(CONF_ATTR_IPV6_ADDR_GEN_MODE),
ip6_privacy=data[CONF_ATTR_IPV6].get(CONF_ATTR_IPV6_PRIVACY),
address_data=address_data,
gateway=data[CONF_ATTR_IPV6].get(CONF_ATTR_IP_GATEWAY),
route_metric=data[CONF_ATTR_IPV6].get(CONF_ATTR_IP_ROUTE_METRIC),
dns=data[CONF_ATTR_IPV6].get(CONF_ATTR_IP_DNS),
gateway=data[CONF_ATTR_IPV6].get(CONF_ATTR_IPV6_GATEWAY),
dns=data[CONF_ATTR_IPV6].get(CONF_ATTR_IPV6_DNS),
)
if CONF_ATTR_MATCH in data:

View File

@@ -16,11 +16,7 @@ from ....host.const import (
InterfaceType,
MulticastDnsMode,
)
from ...const import (
InterfaceAddrGenMode as NMInterfaceAddrGenMode,
InterfaceIp6Privacy as NMInterfaceIp6Privacy,
MulticastDnsValue,
)
from ...const import MulticastDnsValue
from .. import NetworkManager
from . import (
CONF_ATTR_802_ETHERNET,
@@ -41,14 +37,17 @@ from . import (
CONF_ATTR_CONNECTION_MDNS,
CONF_ATTR_CONNECTION_TYPE,
CONF_ATTR_CONNECTION_UUID,
CONF_ATTR_IP_ADDRESS_DATA,
CONF_ATTR_IP_DNS,
CONF_ATTR_IP_GATEWAY,
CONF_ATTR_IP_METHOD,
CONF_ATTR_IP_ROUTE_METRIC,
CONF_ATTR_IPV4,
CONF_ATTR_IPV4_ADDRESS_DATA,
CONF_ATTR_IPV4_DNS,
CONF_ATTR_IPV4_GATEWAY,
CONF_ATTR_IPV4_METHOD,
CONF_ATTR_IPV6,
CONF_ATTR_IPV6_ADDR_GEN_MODE,
CONF_ATTR_IPV6_ADDRESS_DATA,
CONF_ATTR_IPV6_DNS,
CONF_ATTR_IPV6_GATEWAY,
CONF_ATTR_IPV6_METHOD,
CONF_ATTR_IPV6_PRIVACY,
CONF_ATTR_MATCH,
CONF_ATTR_MATCH_PATH,
@@ -72,11 +71,11 @@ MULTICAST_DNS_MODE_VALUE_MAPPING = {
def _get_ipv4_connection_settings(ipv4setting: IpSetting | None) -> dict:
ipv4 = {}
if not ipv4setting or ipv4setting.method == InterfaceMethod.AUTO:
ipv4[CONF_ATTR_IP_METHOD] = Variant("s", "auto")
ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "auto")
elif ipv4setting.method == InterfaceMethod.DISABLED:
ipv4[CONF_ATTR_IP_METHOD] = Variant("s", "disabled")
ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "disabled")
elif ipv4setting.method == InterfaceMethod.STATIC:
ipv4[CONF_ATTR_IP_METHOD] = Variant("s", "manual")
ipv4[CONF_ATTR_IPV4_METHOD] = Variant("s", "manual")
address_data = []
for address in ipv4setting.address:
@@ -87,25 +86,26 @@ def _get_ipv4_connection_settings(ipv4setting: IpSetting | None) -> dict:
}
)
ipv4[CONF_ATTR_IP_ADDRESS_DATA] = Variant("aa{sv}", address_data)
ipv4[CONF_ATTR_IPV4_ADDRESS_DATA] = Variant("aa{sv}", address_data)
if ipv4setting.gateway:
ipv4[CONF_ATTR_IP_GATEWAY] = Variant("s", str(ipv4setting.gateway))
ipv4[CONF_ATTR_IPV4_GATEWAY] = Variant("s", str(ipv4setting.gateway))
else:
raise RuntimeError("Invalid IPv4 InterfaceMethod")
if ipv4setting:
if ipv4setting.route_metric is not None:
ipv4[CONF_ATTR_IP_ROUTE_METRIC] = Variant("i", ipv4setting.route_metric)
if ipv4setting.nameservers and ipv4setting.method in (
if (
ipv4setting
and ipv4setting.nameservers
and ipv4setting.method
in (
InterfaceMethod.AUTO,
InterfaceMethod.STATIC,
):
nameservers = ipv4setting.nameservers if ipv4setting else []
ipv4[CONF_ATTR_IP_DNS] = Variant(
"au",
[socket.htonl(int(ip_address)) for ip_address in nameservers],
)
)
):
nameservers = ipv4setting.nameservers if ipv4setting else []
ipv4[CONF_ATTR_IPV4_DNS] = Variant(
"au",
[socket.htonl(int(ip_address)) for ip_address in nameservers],
)
return ipv4
@@ -115,48 +115,31 @@ def _get_ipv6_connection_settings(
) -> dict:
ipv6 = {}
if not ipv6setting or ipv6setting.method == InterfaceMethod.AUTO:
ipv6[CONF_ATTR_IP_METHOD] = Variant("s", "auto")
ipv6[CONF_ATTR_IPV6_METHOD] = Variant("s", "auto")
if ipv6setting:
if ipv6setting.addr_gen_mode == InterfaceAddrGenMode.EUI64:
ipv6[CONF_ATTR_IPV6_ADDR_GEN_MODE] = Variant(
"i", NMInterfaceAddrGenMode.EUI64.value
)
ipv6[CONF_ATTR_IPV6_ADDR_GEN_MODE] = Variant("i", 0)
elif (
not support_addr_gen_mode_defaults
or ipv6setting.addr_gen_mode == InterfaceAddrGenMode.STABLE_PRIVACY
):
ipv6[CONF_ATTR_IPV6_ADDR_GEN_MODE] = Variant(
"i", NMInterfaceAddrGenMode.STABLE_PRIVACY.value
)
ipv6[CONF_ATTR_IPV6_ADDR_GEN_MODE] = Variant("i", 1)
elif ipv6setting.addr_gen_mode == InterfaceAddrGenMode.DEFAULT_OR_EUI64:
ipv6[CONF_ATTR_IPV6_ADDR_GEN_MODE] = Variant(
"i", NMInterfaceAddrGenMode.DEFAULT_OR_EUI64.value
)
ipv6[CONF_ATTR_IPV6_ADDR_GEN_MODE] = Variant("i", 2)
else:
ipv6[CONF_ATTR_IPV6_ADDR_GEN_MODE] = Variant(
"i", NMInterfaceAddrGenMode.DEFAULT.value
)
ipv6[CONF_ATTR_IPV6_ADDR_GEN_MODE] = Variant("i", 3)
if ipv6setting.ip6_privacy == InterfaceIp6Privacy.DISABLED:
ipv6[CONF_ATTR_IPV6_PRIVACY] = Variant(
"i", NMInterfaceIp6Privacy.DISABLED.value
)
ipv6[CONF_ATTR_IPV6_PRIVACY] = Variant("i", 0)
elif ipv6setting.ip6_privacy == InterfaceIp6Privacy.ENABLED_PREFER_PUBLIC:
ipv6[CONF_ATTR_IPV6_PRIVACY] = Variant(
"i", NMInterfaceIp6Privacy.ENABLED_PREFER_PUBLIC.value
)
ipv6[CONF_ATTR_IPV6_PRIVACY] = Variant("i", 1)
elif ipv6setting.ip6_privacy == InterfaceIp6Privacy.ENABLED:
ipv6[CONF_ATTR_IPV6_PRIVACY] = Variant(
"i", NMInterfaceIp6Privacy.ENABLED.value
)
ipv6[CONF_ATTR_IPV6_PRIVACY] = Variant("i", 2)
else:
ipv6[CONF_ATTR_IPV6_PRIVACY] = Variant(
"i", NMInterfaceIp6Privacy.DEFAULT.value
)
ipv6[CONF_ATTR_IPV6_PRIVACY] = Variant("i", -1)
elif ipv6setting.method == InterfaceMethod.DISABLED:
ipv6[CONF_ATTR_IP_METHOD] = Variant("s", "link-local")
ipv6[CONF_ATTR_IPV6_METHOD] = Variant("s", "link-local")
elif ipv6setting.method == InterfaceMethod.STATIC:
ipv6[CONF_ATTR_IP_METHOD] = Variant("s", "manual")
ipv6[CONF_ATTR_IPV6_METHOD] = Variant("s", "manual")
address_data = []
for address in ipv6setting.address:
@@ -167,26 +150,26 @@ def _get_ipv6_connection_settings(
}
)
ipv6[CONF_ATTR_IP_ADDRESS_DATA] = Variant("aa{sv}", address_data)
ipv6[CONF_ATTR_IPV6_ADDRESS_DATA] = Variant("aa{sv}", address_data)
if ipv6setting.gateway:
ipv6[CONF_ATTR_IP_GATEWAY] = Variant("s", str(ipv6setting.gateway))
ipv6[CONF_ATTR_IPV6_GATEWAY] = Variant("s", str(ipv6setting.gateway))
else:
raise RuntimeError("Invalid IPv6 InterfaceMethod")
if ipv6setting:
if ipv6setting.route_metric is not None:
ipv6[CONF_ATTR_IP_ROUTE_METRIC] = Variant("i", ipv6setting.route_metric)
if ipv6setting.nameservers and ipv6setting.method in (
if (
ipv6setting
and ipv6setting.nameservers
and ipv6setting.method
in (
InterfaceMethod.AUTO,
InterfaceMethod.STATIC,
):
nameservers = ipv6setting.nameservers if ipv6setting else []
ipv6[CONF_ATTR_IP_DNS] = Variant(
"aay",
[ip_address.packed for ip_address in nameservers],
)
)
):
nameservers = ipv6setting.nameservers if ipv6setting else []
ipv6[CONF_ATTR_IPV6_DNS] = Variant(
"aay",
[ip_address.packed for ip_address in nameservers],
)
return ipv6

View File

@@ -1,5 +1,6 @@
"""D-Bus interface for rauc."""
from ctypes import c_uint32, c_uint64
import logging
from typing import Any, NotRequired, TypedDict
@@ -32,15 +33,13 @@ SlotStatusDataType = TypedDict(
"state": str,
"device": str,
"bundle.compatible": NotRequired[str],
"bundle.hash": NotRequired[str],
"sha256": NotRequired[str],
"size": NotRequired[int],
"installed.count": NotRequired[int],
"installed.transaction": NotRequired[str],
"size": NotRequired[c_uint64],
"installed.count": NotRequired[c_uint32],
"bundle.version": NotRequired[str],
"installed.timestamp": NotRequired[str],
"status": NotRequired[str],
"activated.count": NotRequired[int],
"activated.count": NotRequired[c_uint32],
"activated.timestamp": NotRequired[str],
"boot-status": NotRequired[str],
"bootname": NotRequired[str],
@@ -118,7 +117,7 @@ class Rauc(DBusInterfaceProxy):
return self.connected_dbus.signal(DBUS_SIGNAL_RAUC_INSTALLER_COMPLETED)
@dbus_connected
async def mark(self, state: RaucState, slot_identifier: str) -> list[str]:
async def mark(self, state: RaucState, slot_identifier: str) -> tuple[str, str]:
"""Get slot status."""
return await self.connected_dbus.Installer.call("mark", state, slot_identifier)

View File

@@ -103,19 +103,19 @@ class Resolved(DBusInterfaceProxy):
@dbus_property
def dns_over_tls(self) -> DNSOverTLSEnabled | None:
"""Return DNS over TLS enabled."""
return DNSOverTLSEnabled(self.properties[DBUS_ATTR_DNS_OVER_TLS])
return self.properties[DBUS_ATTR_DNS_OVER_TLS]
@property
@dbus_property
def dns_stub_listener(self) -> DNSStubListenerEnabled | None:
"""Return DNS stub listener enabled on port 53."""
return DNSStubListenerEnabled(self.properties[DBUS_ATTR_DNS_STUB_LISTENER])
return self.properties[DBUS_ATTR_DNS_STUB_LISTENER]
@property
@dbus_property
def dnssec(self) -> DNSSECValidation | None:
"""Return DNSSEC validation enforced."""
return DNSSECValidation(self.properties[DBUS_ATTR_DNSSEC])
return self.properties[DBUS_ATTR_DNSSEC]
@property
@dbus_property
@@ -159,7 +159,7 @@ class Resolved(DBusInterfaceProxy):
@dbus_property
def llmnr(self) -> MulticastProtocolEnabled | None:
"""Return LLMNR enabled."""
return MulticastProtocolEnabled(self.properties[DBUS_ATTR_LLMNR])
return self.properties[DBUS_ATTR_LLMNR]
@property
@dbus_property
@@ -171,13 +171,13 @@ class Resolved(DBusInterfaceProxy):
@dbus_property
def multicast_dns(self) -> MulticastProtocolEnabled | None:
"""Return MDNS enabled."""
return MulticastProtocolEnabled(self.properties[DBUS_ATTR_MULTICAST_DNS])
return self.properties[DBUS_ATTR_MULTICAST_DNS]
@property
@dbus_property
def resolv_conf_mode(self) -> ResolvConfMode | None:
"""Return how /etc/resolv.conf managed on host."""
return ResolvConfMode(self.properties[DBUS_ATTR_RESOLV_CONF_MODE])
return self.properties[DBUS_ATTR_RESOLV_CONF_MODE]
@property
@dbus_property

View File

@@ -4,8 +4,6 @@ from enum import StrEnum
from dbus_fast import Variant
from ..enum import DBusStrEnum
UDISKS2_DEFAULT_OPTIONS = {"auth.no_user_interaction": Variant("b", True)}
@@ -33,7 +31,7 @@ class FormatType(StrEnum):
GPT = "gpt"
class PartitionTableType(DBusStrEnum):
class PartitionTableType(StrEnum):
"""Partition Table type."""
DOS = "dos"

View File

@@ -3,16 +3,21 @@
from __future__ import annotations
from contextlib import suppress
from http import HTTPStatus
from ipaddress import IPv4Address
import logging
import os
from pathlib import Path
from socket import SocketIO
import tempfile
from typing import TYPE_CHECKING, Any, Literal, cast
from typing import TYPE_CHECKING, cast
import aiodocker
from attr import evolve
from awesomeversion import AwesomeVersion
import docker
import docker.errors
from docker.types import Mount
import requests
from ..addons.build import AddonBuild
from ..addons.const import MappingType
@@ -30,7 +35,6 @@ from ..coresys import CoreSys
from ..exceptions import (
CoreDNSError,
DBusError,
DockerBuildError,
DockerError,
DockerJobError,
DockerNotFound,
@@ -62,11 +66,8 @@ from .const import (
PATH_SHARE,
PATH_SSL,
Capabilities,
DockerMount,
MountBindOptions,
MountType,
PropagationMode,
Ulimit,
)
from .interface import DockerInterface
@@ -129,7 +130,7 @@ class DockerAddon(DockerInterface):
def arch(self) -> str | None:
"""Return arch of Docker image."""
if self.addon.legacy:
return str(self.sys_arch.default)
return self.sys_arch.default
return super().arch
@property
@@ -269,7 +270,7 @@ class DockerAddon(DockerInterface):
}
@property
def network_mode(self) -> Literal["host"] | None:
def network_mode(self) -> str | None:
"""Return network mode for add-on."""
if self.addon.host_network:
return "host"
@@ -308,28 +309,28 @@ class DockerAddon(DockerInterface):
return None
@property
def ulimits(self) -> list[Ulimit] | None:
def ulimits(self) -> list[docker.types.Ulimit] | None:
"""Generate ulimits for add-on."""
limits: list[Ulimit] = []
limits: list[docker.types.Ulimit] = []
# Need schedule functions
if self.addon.with_realtime:
limits.append(Ulimit(name="rtprio", soft=90, hard=99))
limits.append(docker.types.Ulimit(name="rtprio", soft=90, hard=99))
# Set available memory for memlock to 128MB
mem = 128 * 1024 * 1024
limits.append(Ulimit(name="memlock", soft=mem, hard=mem))
limits.append(docker.types.Ulimit(name="memlock", soft=mem, hard=mem))
# Add configurable ulimits from add-on config
for name, config in self.addon.ulimits.items():
if isinstance(config, int):
# Simple format: both soft and hard limits are the same
limits.append(Ulimit(name=name, soft=config, hard=config))
limits.append(docker.types.Ulimit(name=name, soft=config, hard=config))
elif isinstance(config, dict):
# Detailed format: both soft and hard limits are mandatory
soft = config["soft"]
hard = config["hard"]
limits.append(Ulimit(name=name, soft=soft, hard=hard))
limits.append(docker.types.Ulimit(name=name, soft=soft, hard=hard))
# Return None if no ulimits are present
if limits:
@@ -348,7 +349,7 @@ class DockerAddon(DockerInterface):
return None
@property
def mounts(self) -> list[DockerMount]:
def mounts(self) -> list[Mount]:
"""Return mounts for container."""
addon_mapping = self.addon.map_volumes
@@ -358,8 +359,8 @@ class DockerAddon(DockerInterface):
mounts = [
MOUNT_DEV,
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.addon.path_extern_data.as_posix(),
target=target_data_path or PATH_PRIVATE_DATA.as_posix(),
read_only=False,
@@ -369,8 +370,8 @@ class DockerAddon(DockerInterface):
# setup config mappings
if MappingType.CONFIG in addon_mapping:
mounts.append(
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_config.path_extern_homeassistant.as_posix(),
target=addon_mapping[MappingType.CONFIG].path
or PATH_HOMEASSISTANT_CONFIG_LEGACY.as_posix(),
@@ -382,8 +383,8 @@ class DockerAddon(DockerInterface):
# Map addon's public config folder if not using deprecated config option
if self.addon.addon_config_used:
mounts.append(
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.addon.path_extern_config.as_posix(),
target=addon_mapping[MappingType.ADDON_CONFIG].path
or PATH_PUBLIC_CONFIG.as_posix(),
@@ -394,8 +395,8 @@ class DockerAddon(DockerInterface):
# Map Home Assistant config in new way
if MappingType.HOMEASSISTANT_CONFIG in addon_mapping:
mounts.append(
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_config.path_extern_homeassistant.as_posix(),
target=addon_mapping[MappingType.HOMEASSISTANT_CONFIG].path
or PATH_HOMEASSISTANT_CONFIG.as_posix(),
@@ -407,8 +408,8 @@ class DockerAddon(DockerInterface):
if MappingType.ALL_ADDON_CONFIGS in addon_mapping:
mounts.append(
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_config.path_extern_addon_configs.as_posix(),
target=addon_mapping[MappingType.ALL_ADDON_CONFIGS].path
or PATH_ALL_ADDON_CONFIGS.as_posix(),
@@ -418,8 +419,8 @@ class DockerAddon(DockerInterface):
if MappingType.SSL in addon_mapping:
mounts.append(
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_config.path_extern_ssl.as_posix(),
target=addon_mapping[MappingType.SSL].path or PATH_SSL.as_posix(),
read_only=addon_mapping[MappingType.SSL].read_only,
@@ -428,8 +429,8 @@ class DockerAddon(DockerInterface):
if MappingType.ADDONS in addon_mapping:
mounts.append(
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_config.path_extern_addons_local.as_posix(),
target=addon_mapping[MappingType.ADDONS].path
or PATH_LOCAL_ADDONS.as_posix(),
@@ -439,8 +440,8 @@ class DockerAddon(DockerInterface):
if MappingType.BACKUP in addon_mapping:
mounts.append(
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_config.path_extern_backup.as_posix(),
target=addon_mapping[MappingType.BACKUP].path
or PATH_BACKUP.as_posix(),
@@ -450,25 +451,25 @@ class DockerAddon(DockerInterface):
if MappingType.SHARE in addon_mapping:
mounts.append(
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_config.path_extern_share.as_posix(),
target=addon_mapping[MappingType.SHARE].path
or PATH_SHARE.as_posix(),
read_only=addon_mapping[MappingType.SHARE].read_only,
bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE),
propagation=PropagationMode.RSLAVE,
)
)
if MappingType.MEDIA in addon_mapping:
mounts.append(
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_config.path_extern_media.as_posix(),
target=addon_mapping[MappingType.MEDIA].path
or PATH_MEDIA.as_posix(),
read_only=addon_mapping[MappingType.MEDIA].read_only,
bind_options=MountBindOptions(propagation=PropagationMode.RSLAVE),
propagation=PropagationMode.RSLAVE,
)
)
@@ -480,8 +481,8 @@ class DockerAddon(DockerInterface):
if not Path(gpio_path).exists():
continue
mounts.append(
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=gpio_path,
target=gpio_path,
read_only=False,
@@ -491,8 +492,8 @@ class DockerAddon(DockerInterface):
# DeviceTree support
if self.addon.with_devicetree:
mounts.append(
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source="/sys/firmware/devicetree/base",
target="/device-tree",
read_only=True,
@@ -506,8 +507,8 @@ class DockerAddon(DockerInterface):
# Kernel Modules support
if self.addon.with_kernel_modules:
mounts.append(
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source="/lib/modules",
target="/lib/modules",
read_only=True,
@@ -525,20 +526,20 @@ class DockerAddon(DockerInterface):
# Configuration Audio
if self.addon.with_audio:
mounts += [
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.addon.path_extern_pulse.as_posix(),
target="/etc/pulse/client.conf",
read_only=True,
),
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_plugins.audio.path_extern_pulse.as_posix(),
target="/run/audio",
read_only=True,
),
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_plugins.audio.path_extern_asound.as_posix(),
target="/etc/asound.conf",
read_only=True,
@@ -548,14 +549,14 @@ class DockerAddon(DockerInterface):
# System Journal access
if self.addon.with_journald:
mounts += [
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=SYSTEMD_JOURNAL_PERSISTENT.as_posix(),
target=SYSTEMD_JOURNAL_PERSISTENT.as_posix(),
read_only=True,
),
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=SYSTEMD_JOURNAL_VOLATILE.as_posix(),
target=SYSTEMD_JOURNAL_VOLATILE.as_posix(),
read_only=True,
@@ -681,47 +682,37 @@ class DockerAddon(DockerInterface):
async def _build(self, version: AwesomeVersion, image: str | None = None) -> None:
"""Build a Docker container."""
build_env = await AddonBuild(self.coresys, self.addon).load_config()
# Check if the build environment is valid, raises if not
await build_env.is_valid()
if not await build_env.is_valid():
_LOGGER.error("Invalid build environment, can't build this add-on!")
raise DockerError()
_LOGGER.info("Starting build for %s:%s", self.image, version)
if build_env.squash:
_LOGGER.warning(
"Ignoring squash build option for %s as Docker BuildKit does not support it.",
self.addon.slug,
)
addon_image_tag = f"{image or self.addon.image}:{version!s}"
def build_image():
if build_env.squash:
_LOGGER.warning(
"Ignoring squash build option for %s as Docker BuildKit does not support it.",
self.addon.slug,
)
docker_version = self.sys_docker.info.version
builder_version_tag = (
f"{docker_version.major}.{docker_version.minor}.{docker_version.micro}-cli"
)
addon_image_tag = f"{image or self.addon.image}:{version!s}"
builder_name = f"addon_builder_{self.addon.slug}"
docker_version = self.sys_docker.info.version
builder_version_tag = f"{docker_version.major}.{docker_version.minor}.{docker_version.micro}-cli"
# Remove dangling builder container if it exists by any chance
# E.g. because of an abrupt host shutdown/reboot during a build
try:
container = await self.sys_docker.containers.get(builder_name)
await container.delete(force=True, v=True)
except aiodocker.DockerError as err:
if err.status != HTTPStatus.NOT_FOUND:
raise DockerBuildError(
f"Can't clean up existing builder container: {err!s}", _LOGGER.error
) from err
builder_name = f"addon_builder_{self.addon.slug}"
# Generate Docker config with registry credentials for base image if needed
docker_config_content = build_env.get_docker_config_json()
temp_dir: tempfile.TemporaryDirectory | None = None
# Remove dangling builder container if it exists by any chance
# E.g. because of an abrupt host shutdown/reboot during a build
with suppress(docker.errors.NotFound):
self.sys_docker.containers.get(builder_name).remove(force=True, v=True)
try:
# Generate Docker config with registry credentials for base image if needed
docker_config_path: Path | None = None
docker_config_content = build_env.get_docker_config_json()
temp_dir: tempfile.TemporaryDirectory | None = None
def pre_build_setup() -> tuple[
tempfile.TemporaryDirectory | None, dict[str, Any]
]:
docker_config_path: Path | None = None
temp_dir: tempfile.TemporaryDirectory | None = None
try:
if docker_config_content:
# Create temporary directory for docker config
temp_dir = tempfile.TemporaryDirectory(
@@ -736,54 +727,53 @@ class DockerAddon(DockerInterface):
docker_config_path,
)
return (
temp_dir,
build_env.get_docker_args(
result = self.sys_docker.run_command(
ADDON_BUILDER_IMAGE,
version=builder_version_tag,
name=builder_name,
**build_env.get_docker_args(
version, addon_image_tag, docker_config_path
),
)
finally:
# Clean up temporary directory
if temp_dir:
temp_dir.cleanup()
temp_dir, build_args = await self.sys_run_in_executor(pre_build_setup)
logs = result.output.decode("utf-8")
result = await self.sys_docker.run_command(
ADDON_BUILDER_IMAGE,
tag=builder_version_tag,
name=builder_name,
**build_args,
)
except DockerError as err:
raise DockerBuildError(
f"Can't build {self.image}:{version}: {err!s}", _LOGGER.error
) from err
finally:
# Clean up temporary directory
if temp_dir:
await self.sys_run_in_executor(temp_dir.cleanup)
if result.exit_code != 0:
error_message = f"Docker build failed for {addon_image_tag} (exit code {result.exit_code}). Build output:\n{logs}"
raise docker.errors.DockerException(error_message)
logs = "\n".join(result.log)
if result.exit_code != 0:
raise DockerBuildError(
f"Docker build failed for {addon_image_tag} (exit code {result.exit_code}). Build output:\n{logs}",
_LOGGER.error,
)
_LOGGER.debug("Build %s:%s done: %s", self.image, version, logs)
return addon_image_tag, logs
try:
addon_image_tag, log = await self.sys_run_in_executor(build_image)
_LOGGER.debug("Build %s:%s done: %s", self.image, version, log)
# Update meta data
self._meta = await self.sys_docker.images.inspect(addon_image_tag)
except aiodocker.DockerError as err:
raise DockerBuildError(
f"Can't get image metadata for {addon_image_tag} after build: {err!s}"
) from err
except (
docker.errors.DockerException,
requests.RequestException,
aiodocker.DockerError,
) as err:
_LOGGER.error("Can't build %s:%s: %s", self.image, version, err)
raise DockerError() from err
_LOGGER.info("Build %s:%s done", self.image, version)
async def export_image(self, tar_file: Path) -> None:
"""Export current images into a tar file."""
def export_image(self, tar_file: Path) -> None:
"""Export current images into a tar file.
Must be run in executor.
"""
if not self.image:
raise RuntimeError("Cannot export without image!")
await self.sys_docker.export_image(self.image, self.version, tar_file)
self.sys_docker.export_image(self.image, self.version, tar_file)
@Job(
name="docker_addon_import_image",
@@ -832,24 +822,35 @@ class DockerAddon(DockerInterface):
)
async def write_stdin(self, data: bytes) -> None:
"""Write to add-on stdin."""
if not await self.is_running():
raise DockerError()
await self.sys_run_in_executor(self._write_stdin, data)
def _write_stdin(self, data: bytes) -> None:
"""Write to add-on stdin.
Need run inside executor.
"""
try:
# Load needed docker objects
container = await self.sys_docker.containers.get(self.name)
socket = container.attach(stdin=True)
except aiodocker.DockerError as err:
raise DockerError(
f"Can't attach to {self.name} stdin: {err!s}", _LOGGER.error
) from err
container = self.sys_docker.containers.get(self.name)
# attach_socket returns SocketIO for local Docker connections (Unix socket)
socket = cast(
SocketIO, container.attach_socket(params={"stdin": 1, "stream": 1})
)
except (docker.errors.DockerException, requests.RequestException) as err:
_LOGGER.error("Can't attach to %s stdin: %s", self.name, err)
raise DockerError() from err
try:
await socket.write_in(data + b"\n")
await socket.close()
# Seems to raise very generic exceptions like RuntimeError or AssertionError
# So we catch all exceptions and re-raise them as DockerError
except Exception as err:
raise DockerError(
f"Can't write to {self.name} stdin: {err!s}", _LOGGER.error
) from err
# Write to stdin
data += b"\n"
os.write(socket.fileno(), data)
socket.close()
except OSError as err:
_LOGGER.error("Can't write to %s stdin: %s", self.name, err)
raise DockerError() from err
@Job(
name="docker_addon_stop",
@@ -895,13 +896,15 @@ class DockerAddon(DockerInterface):
return
try:
docker_container = await self.sys_docker.containers.get(self.name)
except aiodocker.DockerError as err:
if err.status == HTTPStatus.NOT_FOUND:
if self._hw_listener:
self.sys_bus.remove_listener(self._hw_listener)
self._hw_listener = None
return
docker_container = await self.sys_run_in_executor(
self.sys_docker.containers.get, self.name
)
except docker.errors.NotFound:
if self._hw_listener:
self.sys_bus.remove_listener(self._hw_listener)
self._hw_listener = None
return
except (docker.errors.DockerException, requests.RequestException) as err:
raise DockerError(
f"Can't process Hardware Event on {self.name}: {err!s}", _LOGGER.error
) from err

View File

@@ -2,6 +2,9 @@
import logging
import docker
from docker.types import Mount
from ..const import DOCKER_CPU_RUNTIME_ALLOCATION
from ..coresys import CoreSysAttributes
from ..exceptions import DockerJobError
@@ -16,9 +19,7 @@ from .const import (
MOUNT_UDEV,
PATH_PRIVATE_DATA,
Capabilities,
DockerMount,
MountType,
Ulimit,
)
from .interface import DockerInterface
@@ -41,12 +42,12 @@ class DockerAudio(DockerInterface, CoreSysAttributes):
return AUDIO_DOCKER_NAME
@property
def mounts(self) -> list[DockerMount]:
def mounts(self) -> list[Mount]:
"""Return mounts for container."""
mounts = [
MOUNT_DEV,
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_config.path_extern_audio.as_posix(),
target=PATH_PRIVATE_DATA.as_posix(),
read_only=False,
@@ -74,10 +75,10 @@ class DockerAudio(DockerInterface, CoreSysAttributes):
return [Capabilities.SYS_NICE, Capabilities.SYS_RESOURCE]
@property
def ulimits(self) -> list[Ulimit]:
def ulimits(self) -> list[docker.types.Ulimit]:
"""Generate ulimits for audio."""
# Pulseaudio by default tries to use real-time scheduling with priority of 5.
return [Ulimit(name="rtprio", soft=10, hard=10)]
return [docker.types.Ulimit(name="rtprio", soft=10, hard=10)]
@property
def cpu_rt_runtime(self) -> int | None:

View File

@@ -2,26 +2,19 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from pathlib import PurePath
import re
from typing import Any
from docker.types import Mount
from ..const import MACHINE_ID
RE_RETRYING_DOWNLOAD_STATUS = re.compile(r"Retrying in \d+ seconds?")
# Docker Hub registry identifier
DOCKER_HUB = "hub.docker.com"
# Docker Hub registry identifier (official default)
# Docker's default registry is docker.io
DOCKER_HUB = "docker.io"
# Docker Hub API endpoint (used for direct registry API calls)
# While docker.io is the registry identifier, registry-1.docker.io is the actual API endpoint
DOCKER_HUB_API = "registry-1.docker.io"
# Legacy Docker Hub identifier for backward compatibility
DOCKER_HUB_LEGACY = "hub.docker.com"
# Regex to match images with a registry host (e.g., ghcr.io/org/image)
IMAGE_WITH_HOST = re.compile(r"^((?:[a-z0-9]+(?:-[a-z0-9]+)*\.)+[a-z]{2,})\/.+")
class Capabilities(StrEnum):
@@ -83,94 +76,33 @@ class PropagationMode(StrEnum):
RSLAVE = "rslave"
@dataclass(slots=True, frozen=True)
class MountBindOptions:
"""Bind options for docker mount."""
propagation: PropagationMode | None = None
read_only_non_recursive: bool | None = None
def to_dict(self) -> dict[str, Any]:
"""To dictionary representation."""
out: dict[str, Any] = {}
if self.propagation:
out["Propagation"] = self.propagation.value
if self.read_only_non_recursive is not None:
out["ReadOnlyNonRecursive"] = self.read_only_non_recursive
return out
@dataclass(slots=True, frozen=True)
class DockerMount:
"""A docker mount."""
type: MountType
source: str
target: str
read_only: bool
bind_options: MountBindOptions | None = None
def to_dict(self) -> dict[str, Any]:
"""To dictionary representation."""
out: dict[str, Any] = {
"Type": self.type.value,
"Source": self.source,
"Target": self.target,
"ReadOnly": self.read_only,
}
if self.bind_options:
out["BindOptions"] = self.bind_options.to_dict()
return out
@dataclass(slots=True, frozen=True)
class Ulimit:
"""A linux user limit."""
name: str
soft: int
hard: int
def to_dict(self) -> dict[str, str | int]:
"""To dictionary representation."""
return {
"Name": self.name,
"Soft": self.soft,
"Hard": self.hard,
}
ENV_DUPLICATE_LOG_FILE = "HA_DUPLICATE_LOG_FILE"
ENV_TIME = "TZ"
ENV_TOKEN = "SUPERVISOR_TOKEN"
ENV_TOKEN_OLD = "HASSIO_TOKEN"
LABEL_MANAGED = "supervisor_managed"
MOUNT_DBUS = DockerMount(
type=MountType.BIND, source="/run/dbus", target="/run/dbus", read_only=True
MOUNT_DBUS = Mount(
type=MountType.BIND.value, source="/run/dbus", target="/run/dbus", read_only=True
)
MOUNT_DEV = DockerMount(
type=MountType.BIND,
source="/dev",
target="/dev",
read_only=True,
bind_options=MountBindOptions(read_only_non_recursive=True),
MOUNT_DEV = Mount(
type=MountType.BIND.value, source="/dev", target="/dev", read_only=True
)
MOUNT_DOCKER = DockerMount(
type=MountType.BIND,
MOUNT_DEV.setdefault("BindOptions", {})["ReadOnlyNonRecursive"] = True
MOUNT_DOCKER = Mount(
type=MountType.BIND.value,
source="/run/docker.sock",
target="/run/docker.sock",
read_only=True,
)
MOUNT_MACHINE_ID = DockerMount(
type=MountType.BIND,
MOUNT_MACHINE_ID = Mount(
type=MountType.BIND.value,
source=MACHINE_ID.as_posix(),
target=MACHINE_ID.as_posix(),
read_only=True,
)
MOUNT_UDEV = DockerMount(
type=MountType.BIND, source="/run/udev", target="/run/udev", read_only=True
MOUNT_UDEV = Mount(
type=MountType.BIND.value, source="/run/udev", target="/run/udev", read_only=True
)
PATH_PRIVATE_DATA = PurePath("/data")

View File

@@ -2,11 +2,13 @@
import logging
from docker.types import Mount
from ..coresys import CoreSysAttributes
from ..exceptions import DockerJobError
from ..jobs.const import JobConcurrency
from ..jobs.decorator import Job
from .const import ENV_TIME, MOUNT_DBUS, DockerMount, MountType
from .const import ENV_TIME, MOUNT_DBUS, MountType
from .interface import DockerInterface
_LOGGER: logging.Logger = logging.getLogger(__name__)
@@ -45,8 +47,8 @@ class DockerDNS(DockerInterface, CoreSysAttributes):
security_opt=self.security_opt,
environment={ENV_TIME: self.sys_timezone},
mounts=[
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_config.path_extern_dns.as_posix(),
target="/config",
read_only=False,

View File

@@ -5,6 +5,7 @@ import logging
import re
from awesomeversion import AwesomeVersion
from docker.types import Mount
from ..const import LABEL_MACHINE
from ..exceptions import DockerJobError
@@ -13,7 +14,6 @@ from ..homeassistant.const import LANDINGPAGE
from ..jobs.const import JobConcurrency
from ..jobs.decorator import Job
from .const import (
ENV_DUPLICATE_LOG_FILE,
ENV_TIME,
ENV_TOKEN,
ENV_TOKEN_OLD,
@@ -25,8 +25,6 @@ from .const import (
PATH_PUBLIC_CONFIG,
PATH_SHARE,
PATH_SSL,
DockerMount,
MountBindOptions,
MountType,
PropagationMode,
)
@@ -92,15 +90,15 @@ class DockerHomeAssistant(DockerInterface):
)
@property
def mounts(self) -> list[DockerMount]:
def mounts(self) -> list[Mount]:
"""Return mounts for container."""
mounts = [
MOUNT_DEV,
MOUNT_DBUS,
MOUNT_UDEV,
# HA config folder
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_config.path_extern_homeassistant.as_posix(),
target=PATH_PUBLIC_CONFIG.as_posix(),
read_only=False,
@@ -112,45 +110,41 @@ class DockerHomeAssistant(DockerInterface):
mounts.extend(
[
# All other folders
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_config.path_extern_ssl.as_posix(),
target=PATH_SSL.as_posix(),
read_only=True,
),
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_config.path_extern_share.as_posix(),
target=PATH_SHARE.as_posix(),
read_only=False,
bind_options=MountBindOptions(
propagation=PropagationMode.RSLAVE
),
propagation=PropagationMode.RSLAVE.value,
),
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_config.path_extern_media.as_posix(),
target=PATH_MEDIA.as_posix(),
read_only=False,
bind_options=MountBindOptions(
propagation=PropagationMode.RSLAVE
),
propagation=PropagationMode.RSLAVE.value,
),
# Configuration audio
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_homeassistant.path_extern_pulse.as_posix(),
target="/etc/pulse/client.conf",
read_only=True,
),
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_plugins.audio.path_extern_pulse.as_posix(),
target="/run/audio",
read_only=True,
),
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_plugins.audio.path_extern_asound.as_posix(),
target="/etc/asound.conf",
read_only=True,
@@ -172,16 +166,14 @@ class DockerHomeAssistant(DockerInterface):
async def run(self, *, restore_job_id: str | None = None) -> None:
"""Run Docker image."""
environment = {
"SUPERVISOR": str(self.sys_docker.network.supervisor),
"HASSIO": str(self.sys_docker.network.supervisor),
"SUPERVISOR": self.sys_docker.network.supervisor,
"HASSIO": self.sys_docker.network.supervisor,
ENV_TIME: self.sys_timezone,
ENV_TOKEN: self.sys_homeassistant.supervisor_token,
ENV_TOKEN_OLD: self.sys_homeassistant.supervisor_token,
}
if restore_job_id:
environment[ENV_RESTORE_JOB_ID] = restore_job_id
if self.sys_homeassistant.duplicate_log_file:
environment[ENV_DUPLICATE_LOG_FILE] = "1"
await self._run(
tag=(self.sys_homeassistant.version),
name=self.name,
@@ -210,30 +202,31 @@ class DockerHomeAssistant(DockerInterface):
on_condition=DockerJobError,
concurrency=JobConcurrency.GROUP_REJECT,
)
async def execute_command(self, command: list[str]) -> CommandReturn:
async def execute_command(self, command: str) -> CommandReturn:
"""Create a temporary container and run command."""
return await self.sys_docker.run_command(
return await self.sys_run_in_executor(
self.sys_docker.run_command,
self.image,
tag=str(self.sys_homeassistant.version),
version=self.sys_homeassistant.version,
command=command,
privileged=True,
init=True,
entrypoint=[],
mounts=[
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_config.path_extern_homeassistant.as_posix(),
target="/config",
read_only=False,
),
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_config.path_extern_ssl.as_posix(),
target="/ssl",
read_only=True,
),
DockerMount(
type=MountType.BIND,
Mount(
type=MountType.BIND.value,
source=self.sys_config.path_extern_share.as_posix(),
target="/share",
read_only=False,

View File

@@ -9,13 +9,14 @@ from contextlib import suppress
from http import HTTPStatus
import logging
from time import time
from typing import Any
from typing import Any, cast
from uuid import uuid4
import aiodocker
import aiohttp
from awesomeversion import AwesomeVersion
from awesomeversion.strategy import AwesomeVersionStrategy
import docker
from docker.models.containers import Container
import requests
from ..const import (
@@ -41,8 +42,8 @@ from ..jobs.decorator import Job
from ..jobs.job_group import JobGroup
from ..resolution.const import ContextType, IssueType, SuggestionType
from ..utils.sentry import async_capture_exception
from .const import DOCKER_HUB, DOCKER_HUB_LEGACY, ContainerState, RestartPolicy
from .manager import CommandReturn, ExecReturn, PullLogEntry
from .const import DOCKER_HUB, ContainerState, RestartPolicy
from .manager import CommandReturn, PullLogEntry
from .monitor import DockerContainerStateEvent
from .pull_progress import ImagePullProgress
from .stats import DockerStats
@@ -58,37 +59,18 @@ MAP_ARCH: dict[CpuArch, str] = {
}
def _restart_policy_from_model(meta_host: dict[str, Any]) -> RestartPolicy | None:
"""Get restart policy from host config model."""
if "RestartPolicy" not in meta_host:
return None
name = meta_host["RestartPolicy"].get("Name")
if not name:
return RestartPolicy.NO
if name in RestartPolicy:
return RestartPolicy(name)
_LOGGER.warning("Unknown Docker restart policy '%s', treating as no", name)
return RestartPolicy.NO
def _container_state_from_model(container_metadata: dict[str, Any]) -> ContainerState:
def _container_state_from_model(docker_container: Container) -> ContainerState:
"""Get container state from model."""
if "State" not in container_metadata:
return ContainerState.UNKNOWN
if container_metadata["State"]["Status"] == "running":
if "Health" in container_metadata["State"]:
if docker_container.status == "running":
if "Health" in docker_container.attrs["State"]:
return (
ContainerState.HEALTHY
if container_metadata["State"]["Health"]["Status"] == "healthy"
if docker_container.attrs["State"]["Health"]["Status"] == "healthy"
else ContainerState.UNHEALTHY
)
return ContainerState.RUNNING
if container_metadata["State"]["ExitCode"] > 0:
if docker_container.attrs["State"]["ExitCode"] > 0:
return ContainerState.FAILED
return ContainerState.STOPPED
@@ -173,7 +155,11 @@ class DockerInterface(JobGroup, ABC):
@property
def restart_policy(self) -> RestartPolicy | None:
"""Return restart policy of container."""
return _restart_policy_from_model(self.meta_host)
if "RestartPolicy" not in self.meta_host:
return None
policy = self.meta_host["RestartPolicy"].get("Name")
return policy if policy else RestartPolicy.NO
@property
def security_opt(self) -> list[str]:
@@ -196,8 +182,7 @@ class DockerInterface(JobGroup, ABC):
stored = self.sys_docker.config.registries[registry]
credentials[ATTR_USERNAME] = stored[ATTR_USERNAME]
credentials[ATTR_PASSWORD] = stored[ATTR_PASSWORD]
# Don't include registry for Docker Hub (both official and legacy)
if registry not in (DOCKER_HUB, DOCKER_HUB_LEGACY):
if registry != DOCKER_HUB:
credentials[ATTR_REGISTRY] = registry
_LOGGER.debug(
@@ -227,64 +212,22 @@ class DockerInterface(JobGroup, ABC):
raise ValueError("Cannot pull without an image!")
image_arch = arch or self.sys_arch.supervisor
platform = MAP_ARCH[image_arch]
pull_progress = ImagePullProgress()
current_job = self.sys_jobs.current
# Try to fetch manifest for accurate size-based progress
# This is optional - if it fails, we fall back to count-based progress
try:
manifest = await self.sys_docker.manifest_fetcher.get_manifest(
image, str(version), platform=platform
)
if manifest:
pull_progress.set_manifest(manifest)
_LOGGER.debug(
"Using manifest for progress: %d layers, %d bytes",
manifest.layer_count,
manifest.total_size,
)
except (aiohttp.ClientError, TimeoutError) as err:
_LOGGER.warning("Could not fetch manifest for progress: %s", err)
async def process_pull_event(event: PullLogEntry) -> None:
"""Process pull event and update job progress."""
if event.job_id != current_job.uuid:
return
try:
# Process event through progress tracker
pull_progress.process_event(event)
# Process event through progress tracker
pull_progress.process_event(event)
# Update job if progress changed significantly (>= 1%)
should_update, progress = pull_progress.should_update_job()
if should_update:
stage = pull_progress.get_stage()
current_job.update(progress=progress, stage=stage)
except ValueError as err:
# Catch ValueError from progress tracking (e.g. "Cannot update a job
# that is done") which can occur under rare event combinations.
# Log with context and send to Sentry. Continue the pull anyway as
# progress updates are informational only.
_LOGGER.warning(
"Received an unprocessable update for pull progress (layer: %s, status: %s, progress: %s): %s",
event.id,
event.status,
event.progress,
err,
)
await async_capture_exception(err)
except Exception as err: # pylint: disable=broad-except
# Catch any other unexpected errors in progress tracking to prevent
# pull from failing. Progress updates are informational - the pull
# itself should continue. Send to Sentry for debugging.
_LOGGER.warning(
"Error updating pull progress (layer: %s, status: %s): %s",
event.id,
event.status,
err,
)
await async_capture_exception(err)
# Update job if progress changed significantly (>= 1%)
should_update, progress = pull_progress.should_update_job()
if should_update:
stage = pull_progress.get_stage()
current_job.update(progress=progress, stage=stage)
listener = self.sys_bus.register_event(
BusEvent.DOCKER_IMAGE_PULL_UPDATE, process_pull_event
@@ -300,7 +243,7 @@ class DockerInterface(JobGroup, ABC):
current_job.uuid,
image,
str(version),
platform=platform,
platform=MAP_ARCH[image_arch],
auth=credentials,
)
@@ -312,6 +255,18 @@ class DockerInterface(JobGroup, ABC):
await self.sys_docker.images.tag(
docker_image["Id"], image, tag="latest"
)
except docker.errors.APIError as err:
if err.status_code == HTTPStatus.TOO_MANY_REQUESTS:
self.sys_resolution.create_issue(
IssueType.DOCKER_RATELIMIT,
ContextType.SYSTEM,
suggestions=[SuggestionType.REGISTRY_LOGIN],
)
raise DockerHubRateLimitExceeded(_LOGGER.error) from err
await async_capture_exception(err)
raise DockerError(
f"Can't install {image}:{version!s}: {err}", _LOGGER.error
) from err
except aiodocker.DockerError as err:
if err.status == HTTPStatus.TOO_MANY_REQUESTS:
self.sys_resolution.create_issue(
@@ -324,6 +279,14 @@ class DockerInterface(JobGroup, ABC):
raise DockerError(
f"Can't install {image}:{version!s}: {err}", _LOGGER.error
) from err
except (
docker.errors.DockerException,
requests.RequestException,
) as err:
await async_capture_exception(err)
raise DockerError(
f"Unknown error with {image}:{version!s} -> {err!s}", _LOGGER.error
) from err
finally:
self.sys_bus.remove_listener(listener)
@@ -336,47 +299,49 @@ class DockerInterface(JobGroup, ABC):
return True
return False
async def _get_container(self) -> dict[str, Any] | None:
"""Get docker container, returns None if not found."""
try:
container = await self.sys_docker.containers.get(self.name)
return await container.show()
except aiodocker.DockerError as err:
if err.status == HTTPStatus.NOT_FOUND:
return None
raise DockerAPIError(
f"Docker API error occurred while getting container information: {err!s}"
) from err
except requests.RequestException as err:
raise DockerRequestError(
f"Error communicating with Docker to get container information: {err!s}"
) from err
async def is_running(self) -> bool:
"""Return True if Docker is running."""
return bool(
(container_metadata := await self._get_container())
and "State" in container_metadata
and container_metadata["State"]["Running"]
)
try:
docker_container = await self.sys_run_in_executor(
self.sys_docker.containers.get, self.name
)
except docker.errors.NotFound:
return False
except docker.errors.DockerException as err:
raise DockerAPIError() from err
except requests.RequestException as err:
raise DockerRequestError() from err
return docker_container.status == "running"
async def current_state(self) -> ContainerState:
"""Return current state of container."""
if container_metadata := await self._get_container():
return _container_state_from_model(container_metadata)
return ContainerState.UNKNOWN
try:
docker_container = await self.sys_run_in_executor(
self.sys_docker.containers.get, self.name
)
except docker.errors.NotFound:
return ContainerState.UNKNOWN
except docker.errors.DockerException as err:
raise DockerAPIError() from err
except requests.RequestException as err:
raise DockerRequestError() from err
return _container_state_from_model(docker_container)
@Job(name="docker_interface_attach", concurrency=JobConcurrency.GROUP_QUEUE)
async def attach(
self, version: AwesomeVersion, *, skip_state_event_if_down: bool = False
) -> None:
"""Attach to running Docker container."""
with suppress(aiodocker.DockerError, requests.RequestException):
docker_container = await self.sys_docker.containers.get(self.name)
self._meta = await docker_container.show()
self.sys_docker.monitor.watch_container(self._meta)
with suppress(docker.errors.DockerException, requests.RequestException):
docker_container = await self.sys_run_in_executor(
self.sys_docker.containers.get, self.name
)
self._meta = docker_container.attrs
self.sys_docker.monitor.watch_container(docker_container)
state = _container_state_from_model(self._meta)
state = _container_state_from_model(docker_container)
if not (
skip_state_event_if_down
and state in [ContainerState.STOPPED, ContainerState.FAILED]
@@ -385,7 +350,7 @@ class DockerInterface(JobGroup, ABC):
self.sys_bus.fire_event(
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
DockerContainerStateEvent(
self.name, state, docker_container.id, int(time())
self.name, state, cast(str, docker_container.id), int(time())
),
)
@@ -397,9 +362,7 @@ class DockerInterface(JobGroup, ABC):
# Successful?
if not self._meta:
raise DockerError(
f"Could not get metadata on container or image for {self.name}"
)
raise DockerError()
_LOGGER.info("Attaching to %s with version %s", self.image, self.version)
@Job(
@@ -411,11 +374,8 @@ class DockerInterface(JobGroup, ABC):
"""Run Docker image."""
raise NotImplementedError()
async def _run(self, *, name: str, **kwargs) -> None:
"""Run Docker image with retry if necessary."""
if not (image := self.image):
raise ValueError(f"Cannot determine image to use to run {self.name}!")
async def _run(self, **kwargs) -> None:
"""Run Docker image with retry inf necessary."""
if await self.is_running():
return
@@ -424,14 +384,16 @@ class DockerInterface(JobGroup, ABC):
# Create & Run container
try:
container_metadata = await self.sys_docker.run(image, name=name, **kwargs)
docker_container = await self.sys_run_in_executor(
self.sys_docker.run, self.image, **kwargs
)
except DockerNotFound as err:
# If image is missing, capture the exception as this shouldn't happen
await async_capture_exception(err)
raise
# Store metadata
self._meta = container_metadata
self._meta = docker_container.attrs
@Job(
name="docker_interface_stop",
@@ -441,8 +403,11 @@ class DockerInterface(JobGroup, ABC):
async def stop(self, remove_container: bool = True) -> None:
"""Stop/remove Docker container."""
with suppress(DockerNotFound):
await self.sys_docker.stop_container(
self.name, self.timeout, remove_container
await self.sys_run_in_executor(
self.sys_docker.stop_container,
self.name,
self.timeout,
remove_container,
)
@Job(
@@ -452,7 +417,7 @@ class DockerInterface(JobGroup, ABC):
)
def start(self) -> Awaitable[None]:
"""Start Docker container."""
return self.sys_docker.start_container(self.name)
return self.sys_run_in_executor(self.sys_docker.start_container, self.name)
@Job(
name="docker_interface_remove",
@@ -545,11 +510,14 @@ class DockerInterface(JobGroup, ABC):
with suppress(DockerError):
await self.stop()
async def logs(self) -> list[str]:
async def logs(self) -> bytes:
"""Return Docker logs of container."""
with suppress(DockerError):
return await self.sys_docker.container_logs(self.name)
return []
return await self.sys_run_in_executor(
self.sys_docker.container_logs, self.name
)
return b""
@Job(name="docker_interface_cleanup", concurrency=JobConcurrency.GROUP_QUEUE)
async def cleanup(
@@ -575,7 +543,9 @@ class DockerInterface(JobGroup, ABC):
)
def restart(self) -> Awaitable[None]:
"""Restart docker container."""
return self.sys_docker.restart_container(self.name, self.timeout)
return self.sys_run_in_executor(
self.sys_docker.restart_container, self.name, self.timeout
)
@Job(
name="docker_interface_execute_command",
@@ -588,12 +558,28 @@ class DockerInterface(JobGroup, ABC):
async def stats(self) -> DockerStats:
"""Read and return stats from container."""
stats = await self.sys_docker.container_stats(self.name)
stats = await self.sys_run_in_executor(
self.sys_docker.container_stats, self.name
)
return DockerStats(stats)
async def is_failed(self) -> bool:
"""Return True if Docker is failing state."""
return await self.current_state() == ContainerState.FAILED
try:
docker_container = await self.sys_run_in_executor(
self.sys_docker.containers.get, self.name
)
except docker.errors.NotFound:
return False
except (docker.errors.DockerException, requests.RequestException) as err:
raise DockerError() from err
# container is not running
if docker_container.status != "exited":
return False
# Check return value
return int(docker_container.attrs["State"]["ExitCode"]) != 0
async def get_latest_version(self) -> AwesomeVersion:
"""Return latest version of local image."""
@@ -631,6 +617,8 @@ class DockerInterface(JobGroup, ABC):
on_condition=DockerJobError,
concurrency=JobConcurrency.GROUP_REJECT,
)
def run_inside(self, command: str) -> Awaitable[ExecReturn]:
def run_inside(self, command: str) -> Awaitable[CommandReturn]:
"""Execute a command inside Docker container."""
return self.sys_docker.container_run_inside(self.name, command)
return self.sys_run_in_executor(
self.sys_docker.container_run_inside, self.name, command
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,352 +0,0 @@
"""Docker registry manifest fetcher.
Fetches image manifests directly from container registries to get layer sizes
before pulling an image. This enables accurate size-based progress tracking.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
import re
from typing import TYPE_CHECKING
import aiohttp
from supervisor.docker.utils import get_registry_from_image
from .const import DOCKER_HUB, DOCKER_HUB_API, DOCKER_HUB_LEGACY
if TYPE_CHECKING:
from ..coresys import CoreSys
_LOGGER = logging.getLogger(__name__)
# Media types for manifest requests
MANIFEST_MEDIA_TYPES = (
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.index.v1+json",
)
@dataclass
class ImageManifest:
"""Container image manifest with layer information."""
digest: str
total_size: int
layers: dict[str, int] # digest -> size in bytes
@property
def layer_count(self) -> int:
"""Return number of layers."""
return len(self.layers)
def parse_image_reference(image: str, tag: str) -> tuple[str, str, str]:
"""Parse image reference into (registry, repository, tag).
Examples:
ghcr.io/home-assistant/home-assistant:2025.1.0
-> (ghcr.io, home-assistant/home-assistant, 2025.1.0)
homeassistant/home-assistant:latest
-> (registry-1.docker.io, homeassistant/home-assistant, latest)
alpine:3.18
-> (registry-1.docker.io, library/alpine, 3.18)
"""
# Check if image has explicit registry host
registry = get_registry_from_image(image)
if registry:
repository = image[len(registry) + 1 :] # Remove "registry/" prefix
else:
registry = DOCKER_HUB
repository = image
# Docker Hub requires "library/" prefix for official images
if "/" not in repository:
repository = f"library/{repository}"
return registry, repository, tag
class RegistryManifestFetcher:
"""Fetches manifests from container registries."""
def __init__(self, coresys: CoreSys) -> None:
"""Initialize the fetcher."""
self.coresys = coresys
@property
def _session(self) -> aiohttp.ClientSession:
"""Return the websession for HTTP requests."""
return self.coresys.websession
def _get_api_endpoint(self, registry: str) -> str:
"""Get the actual API endpoint for a registry.
Translates docker.io to registry-1.docker.io for Docker Hub.
This matches exactly what Docker itself does internally - see daemon/pkg/registry/config.go:49
where Docker hardcodes DefaultRegistryHost = "registry-1.docker.io" for registry operations.
Without this, requests to https://docker.io/v2/... redirect to https://www.docker.com/
"""
return DOCKER_HUB_API if registry == DOCKER_HUB else registry
def _get_credentials(self, registry: str) -> tuple[str, str] | None:
"""Get credentials for registry from Docker config.
Returns (username, password) tuple or None if no credentials.
"""
registries = self.coresys.docker.config.registries
# Map registry hostname to config key
# Docker Hub can be stored as "hub.docker.com" in config
if registry in (DOCKER_HUB, DOCKER_HUB_LEGACY):
if DOCKER_HUB in registries:
creds = registries[DOCKER_HUB]
return creds.get("username"), creds.get("password")
elif registry in registries:
creds = registries[registry]
return creds.get("username"), creds.get("password")
return None
async def _get_auth_token(
self,
registry: str,
repository: str,
) -> str | None:
"""Get authentication token for registry.
Uses the WWW-Authenticate header from a 401 response to discover
the token endpoint, then requests a token with appropriate scope.
"""
api_endpoint = self._get_api_endpoint(registry)
# First, make an unauthenticated request to get WWW-Authenticate header
manifest_url = f"https://{api_endpoint}/v2/{repository}/manifests/latest"
try:
async with self._session.get(manifest_url) as resp:
if resp.status == 200:
# No auth required
return None
if resp.status != 401:
_LOGGER.warning(
"Unexpected status %d from registry %s", resp.status, registry
)
return None
www_auth = resp.headers.get("WWW-Authenticate", "")
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to connect to registry %s: %s", registry, err)
return None
# Parse WWW-Authenticate: Bearer realm="...",service="...",scope="..."
if not www_auth.startswith("Bearer "):
_LOGGER.warning("Unsupported auth type from %s: %s", registry, www_auth)
return None
params = {}
for match in re.finditer(r'(\w+)="([^"]*)"', www_auth):
params[match.group(1)] = match.group(2)
realm = params.get("realm")
service = params.get("service")
if not realm:
_LOGGER.warning("No realm in WWW-Authenticate from %s", registry)
return None
# Build token request URL
token_url = f"{realm}?scope=repository:{repository}:pull"
if service:
token_url += f"&service={service}"
# Check for credentials
auth = None
credentials = self._get_credentials(registry)
if credentials:
username, password = credentials
if username and password:
auth = aiohttp.BasicAuth(username, password)
_LOGGER.debug("Using credentials for %s", registry)
try:
async with self._session.get(token_url, auth=auth) as resp:
if resp.status != 200:
_LOGGER.warning(
"Failed to get token from %s: %d", realm, resp.status
)
return None
data = await resp.json()
return data.get("token") or data.get("access_token")
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to get auth token: %s", err)
return None
async def _fetch_manifest(
self,
registry: str,
repository: str,
reference: str,
token: str | None,
platform: str,
) -> dict | None:
"""Fetch manifest from registry.
If the manifest is a manifest list (multi-arch), fetches the
platform-specific manifest.
"""
api_endpoint = self._get_api_endpoint(registry)
manifest_url = f"https://{api_endpoint}/v2/{repository}/manifests/{reference}"
headers = {"Accept": ", ".join(MANIFEST_MEDIA_TYPES)}
if token:
headers["Authorization"] = f"Bearer {token}"
try:
async with self._session.get(manifest_url, headers=headers) as resp:
if resp.status != 200:
_LOGGER.warning(
"Failed to fetch manifest for %s/%s:%s - %d",
registry,
repository,
reference,
resp.status,
)
return None
manifest = await resp.json()
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch manifest: %s", err)
return None
media_type = manifest.get("mediaType", "")
# Check if this is a manifest list (multi-arch image)
if "list" in media_type or "index" in media_type:
manifests = manifest.get("manifests", [])
if not manifests:
_LOGGER.warning("Empty manifest list for %s/%s", registry, repository)
return None
# Platform format is "linux/amd64", "linux/arm64", etc.
parts = platform.split("/")
if len(parts) < 2:
_LOGGER.warning("Invalid platform format: %s", platform)
return None
target_os, target_arch = parts[0], parts[1]
platform_manifest = None
for m in manifests:
plat = m.get("platform", {})
if (
plat.get("os") == target_os
and plat.get("architecture") == target_arch
):
platform_manifest = m
break
if not platform_manifest:
_LOGGER.warning(
"Platform %s/%s not found in manifest list for %s/%s, "
"cannot use manifest for progress tracking",
target_os,
target_arch,
registry,
repository,
)
return None
# Fetch the platform-specific manifest
return await self._fetch_manifest(
registry,
repository,
platform_manifest["digest"],
token,
platform,
)
return manifest
async def get_manifest(
self,
image: str,
tag: str,
platform: str,
) -> ImageManifest | None:
"""Fetch manifest and extract layer sizes.
Args:
image: Image name (e.g., "ghcr.io/home-assistant/home-assistant")
tag: Image tag (e.g., "2025.1.0")
platform: Target platform (e.g., "linux/amd64")
Returns:
ImageManifest with layer sizes, or None if fetch failed.
"""
registry, repository, tag = parse_image_reference(image, tag)
_LOGGER.debug(
"Fetching manifest for %s/%s:%s (platform=%s)",
registry,
repository,
tag,
platform,
)
# Get auth token
token = await self._get_auth_token(registry, repository)
# Fetch manifest
manifest = await self._fetch_manifest(
registry, repository, tag, token, platform
)
if not manifest:
return None
# Extract layer information
layers = manifest.get("layers", [])
if not layers:
_LOGGER.warning(
"No layers in manifest for %s/%s:%s", registry, repository, tag
)
return None
layer_sizes: dict[str, int] = {}
total_size = 0
for layer in layers:
digest = layer.get("digest", "")
size = layer.get("size", 0)
if digest and size:
# Store by short digest (first 12 chars after sha256:)
short_digest = (
digest.split(":")[1][:12] if ":" in digest else digest[:12]
)
layer_sizes[short_digest] = size
total_size += size
digest = manifest.get("config", {}).get("digest", "")
_LOGGER.debug(
"Manifest for %s/%s:%s - %d layers, %d bytes total",
registry,
repository,
tag,
len(layer_sizes),
total_size,
)
return ImageManifest(
digest=digest,
total_size=total_size,
layers=layer_sizes,
)

View File

@@ -1,25 +1,21 @@
"""Supervisor docker monitor based on events."""
import asyncio
from contextlib import suppress
from dataclasses import dataclass
import logging
from typing import Any
from threading import Thread
import aiodocker
from aiodocker.channel import ChannelSubscriber
from docker.models.containers import Container
from docker.types.daemon import CancellableStream
from ..const import BusEvent
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import HassioError
from ..utils.sentry import async_capture_exception, capture_exception
from .const import LABEL_MANAGED, ContainerState
_LOGGER: logging.Logger = logging.getLogger(__name__)
STOP_MONITOR_TIMEOUT = 5.0
@dataclass(slots=True, frozen=True)
@dataclass
class DockerContainerStateEvent:
"""Event for docker container state change."""
@@ -29,157 +25,71 @@ class DockerContainerStateEvent:
time: int
@dataclass(slots=True, frozen=True)
class DockerEventCallbackTask:
"""Docker event and task spawned for it."""
data: DockerContainerStateEvent
task: asyncio.Task
class DockerMonitor(CoreSysAttributes):
class DockerMonitor(CoreSysAttributes, Thread):
"""Docker monitor for supervisor."""
def __init__(self, coresys: CoreSys, docker_client: aiodocker.Docker):
def __init__(self, coresys: CoreSys):
"""Initialize Docker monitor object."""
super().__init__()
self.coresys = coresys
self.docker = docker_client
self._events: CancellableStream | None = None
self._unlabeled_managed_containers: list[str] = []
self._monitor_task: asyncio.Task | None = None
self._await_task: asyncio.Task | None = None
self._event_tasks: asyncio.Queue[DockerEventCallbackTask | None]
def watch_container(self, container_metadata: dict[str, Any]):
def watch_container(self, container: Container):
"""If container is missing the managed label, add name to list."""
labels: dict[str, str] = container_metadata.get("Config", {}).get("Labels", {})
name: str | None = container_metadata.get("Name")
if name:
name = name.lstrip("/")
if LABEL_MANAGED not in labels and name:
self._unlabeled_managed_containers += [name]
if LABEL_MANAGED not in container.labels and container.name:
self._unlabeled_managed_containers += [container.name]
async def load(self):
"""Start docker events monitor."""
events = self.docker.events.subscribe()
self._event_tasks = asyncio.Queue()
self._monitor_task = self.sys_create_task(self._run(events), eager_start=True)
self._await_task = self.sys_create_task(
self._await_event_tasks(), eager_start=True
)
self._events = self.sys_docker.events
Thread.start(self)
_LOGGER.info("Started docker events monitor")
async def unload(self):
"""Stop docker events monitor."""
await self.docker.events.stop()
tasks = [task for task in (self._monitor_task, self._await_task) if task]
if tasks:
_, pending = await asyncio.wait(tasks, timeout=STOP_MONITOR_TIMEOUT)
if pending:
_LOGGER.warning(
"Timeout stopping docker events monitor, cancelling %s pending task(s)",
len(pending),
)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
self._event_tasks.shutdown(immediate=True)
self._monitor_task = None
self._await_task = None
self._events.close()
with suppress(RuntimeError):
self.join(timeout=5)
_LOGGER.info("Stopped docker events monitor")
async def _run(self, events: ChannelSubscriber) -> None:
def run(self) -> None:
"""Monitor and process docker events."""
try:
while True:
event: dict[str, Any] | None = await events.get()
if event is None:
break
if not self._events:
raise RuntimeError("Monitor has not been loaded!")
try:
attributes: dict[str, str] = event.get("Actor", {}).get(
"Attributes", {}
for event in self._events:
attributes: dict[str, str] = event.get("Actor", {}).get("Attributes", {})
if event["Type"] == "container" and (
LABEL_MANAGED in attributes
or attributes.get("name") in self._unlabeled_managed_containers
):
container_state: ContainerState | None = None
action: str = event["Action"]
if action == "start":
container_state = ContainerState.RUNNING
elif action == "die":
container_state = (
ContainerState.STOPPED
if int(event["Actor"]["Attributes"]["exitCode"]) == 0
else ContainerState.FAILED
)
elif action == "health_status: healthy":
container_state = ContainerState.HEALTHY
elif action == "health_status: unhealthy":
container_state = ContainerState.UNHEALTHY
if event["Type"] == "container" and (
LABEL_MANAGED in attributes
or attributes.get("name") in self._unlabeled_managed_containers
):
container_state: ContainerState | None = None
action: str = event["Action"]
if action == "start":
container_state = ContainerState.RUNNING
elif action == "die":
container_state = (
ContainerState.STOPPED
if int(event["Actor"]["Attributes"]["exitCode"]) == 0
else ContainerState.FAILED
)
elif action == "health_status: healthy":
container_state = ContainerState.HEALTHY
elif action == "health_status: unhealthy":
container_state = ContainerState.UNHEALTHY
if container_state:
state_event = DockerContainerStateEvent(
name=attributes["name"],
state=container_state,
id=event["Actor"]["ID"],
time=event["time"],
)
tasks = self.sys_bus.fire_event(
BusEvent.DOCKER_CONTAINER_STATE_CHANGE, state_event
)
await asyncio.gather(
*[
self._event_tasks.put(
DockerEventCallbackTask(state_event, task)
)
for task in tasks
]
)
# Broad exception here because one bad event cannot stop the monitor
# Log what went wrong and send it to sentry but continue monitoring
except Exception as err: # pylint: disable=broad-exception-caught
await async_capture_exception(err)
_LOGGER.error(
"Could not process docker event, container state my be inaccurate: %s %s",
event,
err,
if container_state:
self.sys_loop.call_soon_threadsafe(
self.sys_bus.fire_event,
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
DockerContainerStateEvent(
name=attributes["name"],
state=container_state,
id=event["Actor"]["ID"],
time=event["time"],
),
)
# Can only get to this except if an error raised while getting events from queue
# Shouldn't really happen but any errors raised there are catastrophic and end the monitor
# Log that the monitor broke and send the details to sentry to review
except Exception as err: # pylint: disable=broad-exception-caught
await async_capture_exception(err)
_LOGGER.error(
"Cannot get events from docker, monitor has crashed. Container "
"state information will be inaccurate: %s",
err,
)
finally:
await self._event_tasks.put(None)
async def _await_event_tasks(self):
"""Await event callback tasks to clean up and capture output."""
while (event := await self._event_tasks.get()) is not None:
try:
await event.task
# Exceptions which inherit from HassioError are already handled
# We can safely ignore these, we only track the unhandled ones here
except HassioError:
pass
except Exception as err: # pylint: disable=broad-exception-caught
capture_exception(err)
_LOGGER.error(
"Error encountered while processing docker container state event: %s %s %s",
event.task.get_name(),
event.data,
err,
)

View File

@@ -1,18 +1,21 @@
"""Internal network manager for Supervisor."""
import asyncio
from contextlib import suppress
from http import HTTPStatus
from ipaddress import IPv4Address
import logging
from typing import Any, Self, cast
from typing import Self, cast
import aiodocker
from aiodocker.networks import DockerNetwork as AiodockerNetwork
import docker
from docker.models.containers import Container
from docker.models.networks import Network
import requests
from ..const import (
ATTR_AUDIO,
ATTR_CLI,
ATTR_DNS,
ATTR_ENABLE_IPV6,
ATTR_OBSERVER,
ATTR_SUPERVISOR,
DOCKER_IPV4_NETWORK_MASK,
@@ -29,112 +32,44 @@ from ..exceptions import DockerError
_LOGGER: logging.Logger = logging.getLogger(__name__)
DOCKER_ENABLEIPV6 = "EnableIPv6"
DOCKER_OPTIONS = "Options"
DOCKER_ENABLE_IPV6_DEFAULT = True
DOCKER_NETWORK_PARAMS = {
"Name": DOCKER_NETWORK,
"Driver": DOCKER_NETWORK_DRIVER,
"IPAM": {
"Driver": "default",
"Config": [
{
"Subnet": str(DOCKER_IPV6_NETWORK_MASK),
},
{
"Subnet": str(DOCKER_IPV4_NETWORK_MASK),
"Gateway": str(DOCKER_IPV4_NETWORK_MASK[1]),
"IPRange": str(DOCKER_IPV4_NETWORK_RANGE),
},
],
},
DOCKER_ENABLEIPV6: DOCKER_ENABLE_IPV6_DEFAULT,
DOCKER_OPTIONS: {"com.docker.network.bridge.name": DOCKER_NETWORK},
"name": DOCKER_NETWORK,
"driver": DOCKER_NETWORK_DRIVER,
"ipam": docker.types.IPAMConfig(
pool_configs=[
docker.types.IPAMPool(subnet=str(DOCKER_IPV6_NETWORK_MASK)),
docker.types.IPAMPool(
subnet=str(DOCKER_IPV4_NETWORK_MASK),
gateway=str(DOCKER_IPV4_NETWORK_MASK[1]),
iprange=str(DOCKER_IPV4_NETWORK_RANGE),
),
]
),
ATTR_ENABLE_IPV6: True,
"options": {"com.docker.network.bridge.name": DOCKER_NETWORK},
}
DOCKER_ENABLE_IPV6_DEFAULT = True
class DockerNetwork:
"""Internal Supervisor Network."""
"""Internal Supervisor Network.
def __init__(self, docker_client: aiodocker.Docker):
This class is not AsyncIO safe!
"""
def __init__(self, docker_client: docker.DockerClient):
"""Initialize internal Supervisor network."""
self.docker: aiodocker.Docker = docker_client
self._network: AiodockerNetwork | None = None
self._network_meta: dict[str, Any] | None = None
self.docker: docker.DockerClient = docker_client
self._network: Network
async def post_init(
self, enable_ipv6: bool | None = None, mtu: int | None = None
) -> Self:
"""Post init actions that must be done in event loop."""
try:
self._network = network = await self.docker.networks.get(DOCKER_NETWORK)
except aiodocker.DockerError as err:
# If network was not found, create it instead. Can skip further checks since it's new
if err.status == HTTPStatus.NOT_FOUND:
await self._create_supervisor_network(enable_ipv6, mtu)
return self
raise DockerError(
f"Could not get network from Docker: {err!s}", _LOGGER.error
) from err
# Cache metadata for network
await self.reload()
current_ipv6: bool = self.network_meta.get(DOCKER_ENABLEIPV6, False)
current_mtu_str: str | None = self.network_meta.get(DOCKER_OPTIONS, {}).get(
"com.docker.network.driver.mtu"
self._network = await asyncio.get_running_loop().run_in_executor(
None, self._get_network, enable_ipv6, mtu
)
current_mtu = int(current_mtu_str) if current_mtu_str is not None else None
# Check if we have explicitly provided settings that differ from what is set
changes = []
if enable_ipv6 is not None and current_ipv6 != enable_ipv6:
changes.append("IPv4/IPv6 Dual-Stack" if enable_ipv6 else "IPv4-Only")
if mtu is not None and current_mtu != mtu:
changes.append(f"MTU {mtu}")
if not changes:
return self
_LOGGER.info("Migrating Supervisor network to %s", ", ".join(changes))
# System is considered running if any containers besides Supervisor and Observer are found
# A reboot is required then, we won't disconnect those containers to remake network
containers: dict[str, dict[str, Any]] = self.network_meta.get("Containers", {})
system_running = containers and any(
container.get("Name") not in (OBSERVER_DOCKER_NAME, SUPERVISOR_DOCKER_NAME)
for container in containers.values()
)
if system_running:
_LOGGER.warning(
"System appears to be running, not applying Supervisor network change. "
"Reboot your system to apply the change."
)
return self
# Disconnect all containers in the network
for c_id, meta in containers.items():
try:
await network.disconnect({"Container": c_id, "Force": True})
except aiodocker.DockerError:
_LOGGER.warning(
"Cannot apply Supervisor network changes because container %s "
"could not be disconnected. Reboot your system to apply change.",
meta.get("Name"),
)
return self
# Remove the network
try:
await network.delete()
except aiodocker.DockerError:
_LOGGER.warning(
"Cannot apply Supervisor network changes because Supervisor network "
"could not be removed and recreated. Reboot your system to apply change."
)
return self
# Recreate it with correct settings
await self._create_supervisor_network(enable_ipv6, mtu)
return self
@property
@@ -143,23 +78,14 @@ class DockerNetwork:
return DOCKER_NETWORK
@property
def network(self) -> AiodockerNetwork:
def network(self) -> Network:
"""Return docker network."""
if not self._network:
raise RuntimeError("Network not set!")
return self._network
@property
def network_meta(self) -> dict[str, Any]:
"""Return docker network metadata."""
if not self._network_meta:
raise RuntimeError("Network metadata not set!")
return self._network_meta
@property
def containers(self) -> dict[str, dict[str, Any]]:
"""Return metadata of connected containers to network."""
return self.network_meta.get("Containers", {})
def containers(self) -> list[str]:
"""Return of connected containers from network."""
return list(self.network.attrs.get("Containers", {}).keys())
@property
def gateway(self) -> IPv4Address:
@@ -191,37 +117,94 @@ class DockerNetwork:
"""Return observer of the network."""
return DOCKER_IPV4_NETWORK_MASK[6]
async def _create_supervisor_network(
def _get_network(
self, enable_ipv6: bool | None = None, mtu: int | None = None
) -> None:
"""Create supervisor network."""
network_params = DOCKER_NETWORK_PARAMS.copy()
) -> Network:
"""Get supervisor network."""
try:
if network := self.docker.networks.get(DOCKER_NETWORK):
current_ipv6 = network.attrs.get(DOCKER_ENABLEIPV6, False)
current_mtu = network.attrs.get("Options", {}).get(
"com.docker.network.driver.mtu"
)
current_mtu = int(current_mtu) if current_mtu else None
if enable_ipv6 is not None:
network_params[DOCKER_ENABLEIPV6] = enable_ipv6
# If the network exists and we don't have explicit settings,
# simply stick with what we have.
if (enable_ipv6 is None or current_ipv6 == enable_ipv6) and (
mtu is None or current_mtu == mtu
):
return network
# We have explicit settings which differ from the current state.
changes = []
if enable_ipv6 is not None and current_ipv6 != enable_ipv6:
changes.append(
"IPv4/IPv6 Dual-Stack" if enable_ipv6 else "IPv4-Only"
)
if mtu is not None and current_mtu != mtu:
changes.append(f"MTU {mtu}")
if changes:
_LOGGER.info(
"Migrating Supervisor network to %s", ", ".join(changes)
)
if (containers := network.containers) and (
containers_all := all(
container.name in (OBSERVER_DOCKER_NAME, SUPERVISOR_DOCKER_NAME)
for container in containers
)
):
for container in containers:
with suppress(
docker.errors.APIError,
docker.errors.DockerException,
requests.RequestException,
):
network.disconnect(container, force=True)
if not containers or containers_all:
try:
network.remove()
except docker.errors.APIError:
_LOGGER.warning("Failed to remove existing Supervisor network")
return network
else:
_LOGGER.warning(
"System appears to be running, "
"not applying Supervisor network change. "
"Reboot your system to apply the change."
)
return network
except docker.errors.NotFound:
_LOGGER.info("Can't find Supervisor network, creating a new network")
network_params = DOCKER_NETWORK_PARAMS.copy()
network_params[ATTR_ENABLE_IPV6] = (
DOCKER_ENABLE_IPV6_DEFAULT if enable_ipv6 is None else enable_ipv6
)
# Copy options and add MTU if specified
if mtu is not None:
options = cast(dict[str, str], network_params[DOCKER_OPTIONS]).copy()
options = cast(dict[str, str], network_params["options"]).copy()
options["com.docker.network.driver.mtu"] = str(mtu)
network_params[DOCKER_OPTIONS] = options
network_params["options"] = options
try:
self._network = await self.docker.networks.create(network_params)
except aiodocker.DockerError as err:
self._network = self.docker.networks.create(**network_params) # type: ignore
except docker.errors.APIError as err:
raise DockerError(
f"Can't create Supervisor network: {err}", _LOGGER.error
) from err
await self.reload()
with suppress(DockerError):
await self.attach_container_by_name(
self.attach_container_by_name(
SUPERVISOR_DOCKER_NAME, [ATTR_SUPERVISOR], self.supervisor
)
with suppress(DockerError):
await self.attach_container_by_name(
self.attach_container_by_name(
OBSERVER_DOCKER_NAME, [ATTR_OBSERVER], self.observer
)
@@ -231,90 +214,101 @@ class DockerNetwork:
(ATTR_AUDIO, self.audio),
):
with suppress(DockerError):
await self.attach_container_by_name(
f"{DOCKER_PREFIX}_{name}", [name], ip
)
self.attach_container_by_name(f"{DOCKER_PREFIX}_{name}", [name], ip)
async def reload(self) -> None:
"""Get and cache metadata for supervisor network."""
try:
self._network_meta = await self.network.show()
except aiodocker.DockerError as err:
raise DockerError(
f"Could not get network metadata from Docker: {err!s}", _LOGGER.error
) from err
return self._network
async def attach_container(
def attach_container(
self,
container_id: str,
name: str | None,
container: Container,
alias: list[str] | None = None,
ipv4: IPv4Address | None = None,
) -> None:
"""Attach container to Supervisor network."""
"""Attach container to Supervisor network.
Need run inside executor.
"""
# Reload Network information
with suppress(DockerError):
await self.reload()
with suppress(docker.errors.DockerException, requests.RequestException):
self.network.reload()
# Check stale Network
if name and name in (val.get("Name") for val in self.containers.values()):
await self.stale_cleanup(name)
if container.name and container.name in (
val.get("Name") for val in self.network.attrs.get("Containers", {}).values()
):
self.stale_cleanup(container.name)
# Attach Network
endpoint_config: dict[str, Any] = {}
if alias:
endpoint_config["Aliases"] = alias
if ipv4:
endpoint_config["IPAMConfig"] = {"IPv4Address": str(ipv4)}
try:
await self.network.connect(
{
"Container": container_id,
"EndpointConfig": endpoint_config,
}
self.network.connect(
container, aliases=alias, ipv4_address=str(ipv4) if ipv4 else None
)
except aiodocker.DockerError as err:
except (
docker.errors.NotFound,
docker.errors.APIError,
docker.errors.DockerException,
requests.RequestException,
) as err:
raise DockerError(
f"Can't connect {name or container_id} to Supervisor network: {err}",
f"Can't connect {container.name} to Supervisor network: {err}",
_LOGGER.error,
) from err
async def attach_container_by_name(
self, name: str, alias: list[str] | None = None, ipv4: IPv4Address | None = None
def attach_container_by_name(
self,
name: str,
alias: list[str] | None = None,
ipv4: IPv4Address | None = None,
) -> None:
"""Attach container to Supervisor network."""
"""Attach container to Supervisor network.
Need run inside executor.
"""
try:
container = await self.docker.containers.get(name)
except aiodocker.DockerError as err:
container = self.docker.containers.get(name)
except (
docker.errors.NotFound,
docker.errors.APIError,
docker.errors.DockerException,
requests.RequestException,
) as err:
raise DockerError(f"Can't find {name}: {err}", _LOGGER.error) from err
if container.id not in self.containers:
await self.attach_container(container.id, name, alias, ipv4)
self.attach_container(container, alias, ipv4)
async def detach_default_bridge(
self, container_id: str, name: str | None = None
) -> None:
"""Detach default Docker bridge."""
def detach_default_bridge(self, container: Container) -> None:
"""Detach default Docker bridge.
Need run inside executor.
"""
try:
default_network = await self.docker.networks.get(DOCKER_NETWORK_DRIVER)
await default_network.disconnect({"Container": container_id})
except aiodocker.DockerError as err:
if err.status == HTTPStatus.NOT_FOUND:
return
default_network = self.docker.networks.get(DOCKER_NETWORK_DRIVER)
default_network.disconnect(container)
except docker.errors.NotFound:
pass
except (
docker.errors.APIError,
docker.errors.DockerException,
requests.RequestException,
) as err:
raise DockerError(
f"Can't disconnect {name or container_id} from default network: {err}",
f"Can't disconnect {container.name} from default network: {err}",
_LOGGER.warning,
) from err
async def stale_cleanup(self, name: str) -> None:
def stale_cleanup(self, name: str) -> None:
"""Force remove a container from Network.
Fix: https://github.com/moby/moby/issues/23302
"""
try:
await self.network.disconnect({"Container": name, "Force": True})
except aiodocker.DockerError as err:
self.network.disconnect(name, force=True)
except (
docker.errors.APIError,
docker.errors.DockerException,
requests.RequestException,
) as err:
raise DockerError(
f"Can't disconnect {name} from Supervisor network: {err}",
_LOGGER.warning,

View File

@@ -2,7 +2,7 @@
import logging
from ..const import DOCKER_IPV4_NETWORK_MASK, OBSERVER_DOCKER_NAME, OBSERVER_PORT
from ..const import DOCKER_IPV4_NETWORK_MASK, OBSERVER_DOCKER_NAME
from ..coresys import CoreSysAttributes
from ..exceptions import DockerJobError
from ..jobs.const import JobConcurrency
@@ -48,10 +48,10 @@ class DockerObserver(DockerInterface, CoreSysAttributes):
environment={
ENV_TIME: self.sys_timezone,
ENV_TOKEN: self.sys_plugins.observer.supervisor_token,
ENV_NETWORK_MASK: str(DOCKER_IPV4_NETWORK_MASK),
ENV_NETWORK_MASK: DOCKER_IPV4_NETWORK_MASK,
},
mounts=[MOUNT_DOCKER],
ports={"80/tcp": OBSERVER_PORT},
ports={"80/tcp": 4357},
oom_score_adj=-300,
)
_LOGGER.info(

View File

@@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from .manager import PullLogEntry
from .manifest import ImageManifest
_LOGGER = logging.getLogger(__name__)
@@ -110,43 +109,23 @@ class LayerProgress:
class ImagePullProgress:
"""Track overall progress of pulling an image.
When manifest layer sizes are provided, uses size-weighted progress where
each layer contributes proportionally to its size. This gives accurate
progress based on actual bytes to download.
Uses count-based progress where each layer contributes equally regardless of size.
This avoids progress regression when large layers are discovered late due to
Docker's rate-limiting of concurrent downloads.
When manifest is not available, falls back to count-based progress where
each layer contributes equally.
Layers that already exist locally are excluded from the progress calculation.
Progress is only reported after the first "Downloading" event, since Docker
sends "Already exists" and "Pulling fs layer" events before we know the full
layer count.
"""
layers: dict[str, LayerProgress] = field(default_factory=dict)
_last_reported_progress: float = field(default=0.0, repr=False)
_seen_downloading: bool = field(default=False, repr=False)
_manifest_layer_sizes: dict[str, int] = field(default_factory=dict, repr=False)
_total_manifest_size: int = field(default=0, repr=False)
def set_manifest(self, manifest: ImageManifest) -> None:
"""Set manifest layer sizes for accurate size-based progress.
Should be called before processing pull events.
"""
self._manifest_layer_sizes = dict(manifest.layers)
self._total_manifest_size = manifest.total_size
_LOGGER.debug(
"Manifest set: %d layers, %d bytes total",
len(self._manifest_layer_sizes),
self._total_manifest_size,
)
def get_or_create_layer(self, layer_id: str) -> LayerProgress:
"""Get existing layer or create new one."""
if layer_id not in self.layers:
# If we have manifest sizes, pre-populate the layer's total_size
manifest_size = self._manifest_layer_sizes.get(layer_id, 0)
self.layers[layer_id] = LayerProgress(
layer_id=layer_id, total_size=manifest_size
)
self.layers[layer_id] = LayerProgress(layer_id=layer_id)
return self.layers[layer_id]
def process_event(self, entry: PullLogEntry) -> None:
@@ -182,9 +161,12 @@ class ImagePullProgress:
if status is LayerPullStatus.DOWNLOADING:
# Mark that we've seen downloading - now we know layer count is complete
self._seen_downloading = True
if entry.progress_detail and entry.progress_detail.current is not None:
if (
entry.progress_detail
and entry.progress_detail.current is not None
and entry.progress_detail.total is not None
):
layer.download_current = entry.progress_detail.current
if entry.progress_detail and entry.progress_detail.total is not None:
# Only set total_size if not already set or if this is larger
# (handles case where total changes during download)
layer.total_size = max(layer.total_size, entry.progress_detail.total)
@@ -255,13 +237,8 @@ class ImagePullProgress:
def calculate_progress(self) -> float:
"""Calculate overall progress 0-100.
When manifest layer sizes are available, uses size-weighted progress
where each layer contributes proportionally to its size.
When manifest is not available, falls back to count-based progress
where each layer contributes equally.
Layers that already exist locally are excluded from the calculation.
Uses count-based progress where each layer that needs pulling contributes
equally. Layers that already exist locally are excluded from the calculation.
Returns 0 until we've seen the first "Downloading" event, since Docker
reports "Already exists" and "Pulling fs layer" events before we know
@@ -281,38 +258,9 @@ class ImagePullProgress:
# All layers already exist, nothing to download
return 100.0
# Use size-weighted progress if manifest sizes are available
if self._manifest_layer_sizes:
return min(100, self._calculate_size_weighted_progress(layers_to_pull))
# Fall back to count-based progress
# Each layer contributes equally: sum of layer progresses / total layers
total_progress = sum(layer.calculate_progress() for layer in layers_to_pull)
return min(100, total_progress / len(layers_to_pull))
def _calculate_size_weighted_progress(
self, layers_to_pull: list[LayerProgress]
) -> float:
"""Calculate size-weighted progress.
Each layer contributes to progress proportionally to its size.
Progress = sum(layer_progress * layer_size) / total_size
"""
# Calculate total size of layers that need pulling
total_size = sum(layer.total_size for layer in layers_to_pull)
if total_size == 0:
# No size info available, fall back to count-based
total_progress = sum(layer.calculate_progress() for layer in layers_to_pull)
return total_progress / len(layers_to_pull)
# Weight each layer's progress by its size
weighted_progress = 0.0
for layer in layers_to_pull:
if layer.total_size > 0:
layer_weight = layer.total_size / total_size
weighted_progress += layer.calculate_progress() * layer_weight
return weighted_progress
return total_progress / len(layers_to_pull)
def get_stage(self) -> str | None:
"""Get current stage based on layer states."""

View File

@@ -1,12 +1,15 @@
"""Init file for Supervisor Docker object."""
import asyncio
from collections.abc import Awaitable
from ipaddress import IPv4Address
import logging
import os
import aiodocker
from awesomeversion.awesomeversion import AwesomeVersion
import docker
import requests
from ..exceptions import DockerError
from ..jobs.const import JobConcurrency
@@ -50,13 +53,13 @@ class DockerSupervisor(DockerInterface):
) -> None:
"""Attach to running docker container."""
try:
docker_container = await self.sys_docker.containers.get(self.name)
self._meta = await docker_container.show()
except aiodocker.DockerError as err:
raise DockerError(
f"Could not get supervisor container metadata: {err!s}"
) from err
docker_container = await self.sys_run_in_executor(
self.sys_docker.containers.get, self.name
)
except (docker.errors.DockerException, requests.RequestException) as err:
raise DockerError() from err
self._meta = docker_container.attrs
_LOGGER.info(
"Attaching to Supervisor %s with version %s",
self.image,
@@ -69,40 +72,40 @@ class DockerSupervisor(DockerInterface):
# Attach to network
_LOGGER.info("Connecting Supervisor to hassio-network")
await self.sys_docker.network.attach_container(
docker_container.id,
self.name,
await self.sys_run_in_executor(
self.sys_docker.network.attach_container,
docker_container,
alias=["supervisor"],
ipv4=self.sys_docker.network.supervisor,
)
@Job(name="docker_supervisor_retag", concurrency=JobConcurrency.GROUP_QUEUE)
async def retag(self) -> None:
def retag(self) -> Awaitable[None]:
"""Retag latest image to version."""
return self.sys_run_in_executor(self._retag)
def _retag(self) -> None:
"""Retag latest image to version.
Need run inside executor.
"""
try:
docker_container = await self.sys_docker.containers.get(self.name)
container_metadata = await docker_container.show()
except aiodocker.DockerError as err:
docker_container = self.sys_docker.containers.get(self.name)
except (docker.errors.DockerException, requests.RequestException) as err:
raise DockerError(
f"Could not get Supervisor container for retag: {err}", _LOGGER.error
) from err
# See https://github.com/docker/docker-py/blob/df3f8e2abc5a03de482e37214dddef9e0cee1bb1/docker/models/containers.py#L41
metadata_image = container_metadata.get("ImageID", container_metadata["Image"])
if not self.image or not metadata_image:
if not self.image or not docker_container.image:
raise DockerError(
"Could not locate image from container metadata for retag",
_LOGGER.error,
)
try:
await asyncio.gather(
self.sys_docker.images.tag(
metadata_image, self.image, tag=str(self.version)
),
self.sys_docker.images.tag(metadata_image, self.image, tag="latest"),
)
except aiodocker.DockerError as err:
docker_container.image.tag(self.image, tag=str(self.version))
docker_container.image.tag(self.image, tag="latest")
except (docker.errors.DockerException, requests.RequestException) as err:
raise DockerError(
f"Can't retag Supervisor version: {err}", _LOGGER.error
) from err
@@ -114,38 +117,28 @@ class DockerSupervisor(DockerInterface):
async def update_start_tag(self, image: str, version: AwesomeVersion) -> None:
"""Update start tag to new version."""
try:
docker_container = await self.sys_docker.containers.get(self.name)
container_metadata = await docker_container.show()
except aiodocker.DockerError as err:
docker_container = await self.sys_run_in_executor(
self.sys_docker.containers.get, self.name
)
docker_image = await self.sys_docker.images.inspect(f"{image}:{version!s}")
except (
aiodocker.DockerError,
docker.errors.DockerException,
requests.RequestException,
) as err:
raise DockerError(
f"Can't get container to fix start tag: {err}", _LOGGER.error
f"Can't get image or container to fix start tag: {err}", _LOGGER.error
) from err
# See https://github.com/docker/docker-py/blob/df3f8e2abc5a03de482e37214dddef9e0cee1bb1/docker/models/containers.py#L41
metadata_image = container_metadata.get("ImageID", container_metadata["Image"])
if not metadata_image:
if not docker_container.image:
raise DockerError(
"Cannot locate image from container metadata to fix start tag",
_LOGGER.error,
)
try:
container_image, new_image = await asyncio.gather(
self.sys_docker.images.inspect(metadata_image),
self.sys_docker.images.inspect(f"{image}:{version!s}"),
)
except aiodocker.DockerError as err:
raise DockerError(
f"Can't get image metadata to fix start tag: {err}", _LOGGER.error
) from err
try:
# Find start tag
for tag in container_image["RepoTags"]:
# See https://github.com/docker/docker-py/blob/df3f8e2abc5a03de482e37214dddef9e0cee1bb1/docker/models/images.py#L47
if tag == "<none>:<none>":
continue
for tag in docker_container.image.tags:
start_image = tag.partition(":")[0]
start_tag = tag.partition(":")[2] or "latest"
@@ -154,12 +147,12 @@ class DockerSupervisor(DockerInterface):
continue
await asyncio.gather(
self.sys_docker.images.tag(
new_image["Id"], start_image, tag=start_tag
docker_image["Id"], start_image, tag=start_tag
),
self.sys_docker.images.tag(
new_image["Id"], start_image, tag=version.string
docker_image["Id"], start_image, tag=version.string
),
)
except aiodocker.DockerError as err:
except (aiodocker.DockerError, requests.RequestException) as err:
raise DockerError(f"Can't fix start tag: {err}", _LOGGER.error) from err

View File

@@ -1,57 +0,0 @@
"""Docker utilities."""
from __future__ import annotations
import re
# Docker image reference domain regex
# Based on Docker's reference implementation:
# vendor/github.com/distribution/reference/normalize.go
#
# A domain is detected if the part before the first / contains:
# - "localhost" (with optional port)
# - Contains "." (like registry.example.com or 127.0.0.1)
# - Contains ":" (like myregistry:5000)
# - IPv6 addresses in brackets (like [::1]:5000)
#
# Note: Docker also treats uppercase letters as registry indicators since
# namespaces must be lowercase, but this regex handles lowercase matching
# and the get_registry_from_image() function validates the registry rules.
IMAGE_REGISTRY_REGEX = re.compile(
r"^(?P<registry>"
r"localhost(?::[0-9]+)?|" # localhost with optional port
r"(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])" # domain component
r"(?:\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*" # more components
r"(?::[0-9]+)?|" # optional port
r"\[[a-fA-F0-9:]+\](?::[0-9]+)?" # IPv6 with optional port
r")/" # must be followed by /
)
def get_registry_from_image(image_ref: str) -> str | None:
"""Extract registry from Docker image reference.
Returns the registry if the image reference contains one,
or None if the image uses Docker Hub (docker.io).
Based on Docker's reference implementation:
vendor/github.com/distribution/reference/normalize.go
Examples:
get_registry_from_image("nginx") -> None (docker.io)
get_registry_from_image("library/nginx") -> None (docker.io)
get_registry_from_image("myregistry.com/nginx") -> "myregistry.com"
get_registry_from_image("localhost/myimage") -> "localhost"
get_registry_from_image("localhost:5000/myimage") -> "localhost:5000"
get_registry_from_image("registry.io:5000/org/app:v1") -> "registry.io:5000"
get_registry_from_image("[::1]:5000/myimage") -> "[::1]:5000"
"""
match = IMAGE_REGISTRY_REGEX.match(image_ref)
if match:
registry = match.group("registry")
# Must contain '.' or ':' or be 'localhost' to be a real registry
# This prevents treating "myuser/myimage" as having registry "myuser"
if "." in registry or ":" in registry or registry == "localhost":
return registry
return None # No registry = Docker Hub (docker.io)

View File

@@ -1,27 +1,25 @@
"""Core Exceptions."""
from collections.abc import Callable, Mapping
from collections.abc import Callable
from typing import Any
from .const import OBSERVER_PORT
MESSAGE_CHECK_SUPERVISOR_LOGS = (
"Check supervisor logs for details (check with '{logs_command}')"
)
EXTRA_FIELDS_LOGS_COMMAND = {"logs_command": "ha supervisor logs"}
class HassioError(Exception):
"""Root exception."""
error_key: str | None = None
message_template: str | None = None
extra_fields: dict[str, Any] | None = None
def __init__(
self, message: str | None = None, logger: Callable[..., None] | None = None
self,
message: str | None = None,
logger: Callable[..., None] | None = None,
*,
extra_fields: dict[str, Any] | None = None,
) -> None:
"""Raise & log."""
self.extra_fields = extra_fields or {}
if not message and self.message_template:
message = (
self.message_template.format(**self.extra_fields)
@@ -43,94 +41,6 @@ class HassioNotSupportedError(HassioError):
"""Function is not supported."""
# API
class APIError(HassioError, RuntimeError):
"""API errors."""
status = 400
headers: Mapping[str, str] | None = None
def __init__(
self,
message: str | None = None,
logger: Callable[..., None] | None = None,
*,
headers: Mapping[str, str] | None = None,
job_id: str | None = None,
) -> None:
"""Raise & log, optionally with job."""
super().__init__(message, logger)
self.headers = headers
self.job_id = job_id
class APIUnauthorized(APIError):
"""API unauthorized error."""
status = 401
class APIForbidden(APIError):
"""API forbidden error."""
status = 403
class APINotFound(APIError):
"""API not found error."""
status = 404
class APIGone(APIError):
"""API is no longer available."""
status = 410
class APITooManyRequests(APIError):
"""API too many requests error."""
status = 429
class APIInternalServerError(APIError):
"""API internal server error."""
status = 500
class APIAddonNotInstalled(APIError):
"""Not installed addon requested at addons API."""
class APIDBMigrationInProgress(APIError):
"""Service is unavailable due to an offline DB migration is in progress."""
status = 503
class APIUnknownSupervisorError(APIError):
"""Unknown error occurred within supervisor. Adds supervisor check logs rider to message template."""
status = 500
def __init__(
self,
logger: Callable[..., None] | None = None,
*,
job_id: str | None = None,
) -> None:
"""Initialize exception."""
self.message_template = (
f"{self.message_template}. {MESSAGE_CHECK_SUPERVISOR_LOGS}"
)
self.extra_fields = (self.extra_fields or {}) | EXTRA_FIELDS_LOGS_COMMAND
super().__init__(None, logger, job_id=job_id)
# JobManager
@@ -212,13 +122,6 @@ class SupervisorAppArmorError(SupervisorError):
"""Supervisor AppArmor error."""
class SupervisorUnknownError(SupervisorError, APIUnknownSupervisorError):
"""Raise when an unknown error occurs interacting with Supervisor or its container."""
error_key = "supervisor_unknown_error"
message_template = "An unknown error occurred with Supervisor"
class SupervisorJobError(SupervisorError, JobException):
"""Raise on job errors."""
@@ -291,18 +194,6 @@ class ObserverJobError(ObserverError, PluginJobError):
"""Raise on job error with observer plugin."""
class ObserverPortConflict(ObserverError, APIError):
"""Raise if observer cannot start due to a port conflict."""
error_key = "observer_port_conflict"
message_template = "Cannot start {observer} because port {port} is already in use"
extra_fields = {"observer": "observer", "port": OBSERVER_PORT}
def __init__(self, logger: Callable[..., None] | None = None) -> None:
"""Raise & log."""
super().__init__(None, logger)
# Multicast
@@ -359,68 +250,6 @@ class AddonConfigurationError(AddonsError):
"""Error with add-on configuration."""
class AddonConfigurationInvalidError(AddonConfigurationError, APIError):
"""Raise if invalid configuration provided for addon."""
error_key = "addon_configuration_invalid_error"
message_template = "Add-on {addon} has invalid options: {validation_error}"
def __init__(
self,
logger: Callable[..., None] | None = None,
*,
addon: str,
validation_error: str,
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon, "validation_error": validation_error}
super().__init__(None, logger)
class AddonBootConfigCannotChangeError(AddonsError, APIError):
"""Raise if user attempts to change addon boot config when it can't be changed."""
error_key = "addon_boot_config_cannot_change_error"
message_template = (
"Addon {addon} boot option is set to {boot_config} so it cannot be changed"
)
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str, boot_config: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon, "boot_config": boot_config}
super().__init__(None, logger)
class AddonNotRunningError(AddonsError, APIError):
"""Raise when an addon is not running."""
error_key = "addon_not_running_error"
message_template = "Add-on {addon} is not running"
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(None, logger)
class AddonPortConflict(AddonsError, APIError):
"""Raise if addon cannot start due to a port conflict."""
error_key = "addon_port_conflict"
message_template = "Cannot start addon {name} because port {port} is already in use"
def __init__(
self, logger: Callable[..., None] | None = None, *, name: str, port: int
) -> None:
"""Raise & log."""
self.extra_fields = {"name": name, "port": port}
super().__init__(None, logger)
class AddonNotSupportedError(HassioNotSupportedError):
"""Addon doesn't support a function."""
@@ -439,8 +268,11 @@ class AddonNotSupportedArchitectureError(AddonNotSupportedError):
architectures: list[str],
) -> None:
"""Initialize exception."""
self.extra_fields = {"slug": slug, "architectures": ", ".join(architectures)}
super().__init__(None, logger)
super().__init__(
None,
logger,
extra_fields={"slug": slug, "architectures": ", ".join(architectures)},
)
class AddonNotSupportedMachineTypeError(AddonNotSupportedError):
@@ -457,8 +289,11 @@ class AddonNotSupportedMachineTypeError(AddonNotSupportedError):
machine_types: list[str],
) -> None:
"""Initialize exception."""
self.extra_fields = {"slug": slug, "machine_types": ", ".join(machine_types)}
super().__init__(None, logger)
super().__init__(
None,
logger,
extra_fields={"slug": slug, "machine_types": ", ".join(machine_types)},
)
class AddonNotSupportedHomeAssistantVersionError(AddonNotSupportedError):
@@ -475,96 +310,11 @@ class AddonNotSupportedHomeAssistantVersionError(AddonNotSupportedError):
version: str,
) -> None:
"""Initialize exception."""
self.extra_fields = {"slug": slug, "version": version}
super().__init__(None, logger)
class AddonNotSupportedWriteStdinError(AddonNotSupportedError, APIError):
"""Addon does not support writing to stdin."""
error_key = "addon_not_supported_write_stdin_error"
message_template = "Add-on {addon} does not support writing to stdin"
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(None, logger)
class AddonBuildDockerfileMissingError(AddonNotSupportedError, APIError):
"""Raise when addon build invalid because dockerfile is missing."""
error_key = "addon_build_dockerfile_missing_error"
message_template = (
"Cannot build addon '{addon}' because dockerfile is missing. A repair "
"using '{repair_command}' will fix this if the cause is data "
"corruption. Otherwise please report this to the addon developer."
)
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon, "repair_command": "ha supervisor repair"}
super().__init__(None, logger)
class AddonBuildArchitectureNotSupportedError(AddonNotSupportedError, APIError):
"""Raise when addon cannot be built on system because it doesn't support its architecture."""
error_key = "addon_build_architecture_not_supported_error"
message_template = (
"Cannot build addon '{addon}' because its supported architectures "
"({addon_arches}) do not match the system supported architectures ({system_arches})"
)
def __init__(
self,
logger: Callable[..., None] | None = None,
*,
addon: str,
addon_arch_list: list[str],
system_arch_list: list[str],
) -> None:
"""Initialize exception."""
self.extra_fields = {
"addon": addon,
"addon_arches": ", ".join(addon_arch_list),
"system_arches": ", ".join(system_arch_list),
}
super().__init__(None, logger)
class AddonUnknownError(AddonsError, APIUnknownSupervisorError):
"""Raise when unknown error occurs taking an action for an addon."""
error_key = "addon_unknown_error"
message_template = "An unknown error occurred with addon {addon}"
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
class AddonBuildFailedUnknownError(AddonsError, APIUnknownSupervisorError):
"""Raise when the build failed for an addon due to an unknown error."""
error_key = "addon_build_failed_unknown_error"
message_template = (
"An unknown error occurred while trying to build the image for addon {addon}"
)
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(logger)
super().__init__(
None,
logger,
extra_fields={"slug": slug, "version": version},
)
class AddonsJobError(AddonsError, JobException):
@@ -596,64 +346,13 @@ class AuthError(HassioError):
"""Auth errors."""
class AuthPasswordResetError(AuthError, APIError):
class AuthPasswordResetError(HassioError):
"""Auth error if password reset failed."""
error_key = "auth_password_reset_error"
message_template = "Username '{user}' does not exist. Check list of users using '{auth_list_command}'."
def __init__(
self,
logger: Callable[..., None] | None = None,
*,
user: str,
) -> None:
"""Initialize exception."""
self.extra_fields = {"user": user, "auth_list_command": "ha auth list"}
super().__init__(None, logger)
class AuthListUsersError(AuthError, APIUnknownSupervisorError):
class AuthListUsersError(HassioError):
"""Auth error if listing users failed."""
error_key = "auth_list_users_error"
message_template = "Can't request listing users on Home Assistant"
class AuthListUsersNoneResponseError(AuthError, APIInternalServerError):
"""Auth error if listing users returned invalid None response."""
error_key = "auth_list_users_none_response_error"
message_template = "Home Assistant returned invalid response of `{none}` instead of a list of users. Check Home Assistant logs for details (check with `{logs_command}`)"
extra_fields = {"none": "None", "logs_command": "ha core logs"}
def __init__(self, logger: Callable[..., None] | None = None) -> None:
"""Initialize exception."""
super().__init__(None, logger)
class AuthInvalidNonStringValueError(AuthError, APIUnauthorized):
"""Auth error if something besides a string provided as username or password."""
error_key = "auth_invalid_non_string_value_error"
message_template = "Username and password must be strings"
def __init__(
self,
logger: Callable[..., None] | None = None,
*,
headers: Mapping[str, str] | None = None,
) -> None:
"""Initialize exception."""
super().__init__(None, logger, headers=headers)
class AuthHomeAssistantAPIValidationError(AuthError, APIUnknownSupervisorError):
"""Error encountered trying to validate auth details via Home Assistant API."""
error_key = "auth_home_assistant_api_validation_error"
message_template = "Unable to validate authentication details with Home Assistant"
# Host
@@ -686,6 +385,60 @@ class HostLogError(HostError):
"""Internal error with host log."""
# API
class APIError(HassioError, RuntimeError):
"""API errors."""
status = 400
def __init__(
self,
message: str | None = None,
logger: Callable[..., None] | None = None,
*,
job_id: str | None = None,
error: HassioError | None = None,
) -> None:
"""Raise & log, optionally with job."""
# Allow these to be set from another error here since APIErrors essentially wrap others to add a status
self.error_key = error.error_key if error else None
self.message_template = error.message_template if error else None
super().__init__(
message, logger, extra_fields=error.extra_fields if error else None
)
self.job_id = job_id
class APIForbidden(APIError):
"""API forbidden error."""
status = 403
class APINotFound(APIError):
"""API not found error."""
status = 404
class APIGone(APIError):
"""API is no longer available."""
status = 410
class APIAddonNotInstalled(APIError):
"""Not installed addon requested at addons API."""
class APIDBMigrationInProgress(APIError):
"""Service is unavailable due to an offline DB migration is in progress."""
status = 503
# Service / Discovery
@@ -863,10 +616,6 @@ class DockerError(HassioError):
"""Docker API/Transport errors."""
class DockerBuildError(DockerError):
"""Docker error during build."""
class DockerAPIError(DockerError):
"""Docker API error."""
@@ -894,23 +643,7 @@ class DockerNoSpaceOnDevice(DockerError):
super().__init__(None, logger=logger)
class DockerContainerPortConflict(DockerError, APIError):
"""Raise if docker cannot start a container due to a port conflict."""
error_key = "docker_container_port_conflict"
message_template = (
"Cannot start container {name} because port {port} is already in use"
)
def __init__(
self, logger: Callable[..., None] | None = None, *, name: str, port: int
) -> None:
"""Raise & log."""
self.extra_fields = {"name": name, "port": port}
super().__init__(None, logger)
class DockerHubRateLimitExceeded(DockerError, APITooManyRequests):
class DockerHubRateLimitExceeded(DockerError):
"""Raise for docker hub rate limit exceeded error."""
error_key = "dockerhub_rate_limit_exceeded"
@@ -918,13 +651,16 @@ class DockerHubRateLimitExceeded(DockerError, APITooManyRequests):
"Your IP address has made too many requests to Docker Hub which activated a rate limit. "
"For more details see {dockerhub_rate_limit_url}"
)
extra_fields = {
"dockerhub_rate_limit_url": "https://www.home-assistant.io/more-info/dockerhub-rate-limit"
}
def __init__(self, logger: Callable[..., None] | None = None) -> None:
"""Raise & log."""
super().__init__(None, logger=logger)
super().__init__(
None,
logger=logger,
extra_fields={
"dockerhub_rate_limit_url": "https://www.home-assistant.io/more-info/dockerhub-rate-limit"
},
)
class DockerJobError(DockerError, JobException):
@@ -995,32 +731,6 @@ class StoreNotFound(StoreError):
"""Raise if slug is not known."""
class StoreAddonNotFoundError(StoreError, APINotFound):
"""Raise if a requested addon is not in the store."""
error_key = "store_addon_not_found_error"
message_template = "Addon {addon} does not exist in the store"
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon}
super().__init__(None, logger)
class StoreRepositoryLocalCannotReset(StoreError, APIError):
"""Raise if user requests a reset on the local addon repository."""
error_key = "store_repository_local_cannot_reset"
message_template = "Can't reset repository {local_repo} as it is not git based!"
extra_fields = {"local_repo": "local"}
def __init__(self, logger: Callable[..., None] | None = None) -> None:
"""Initialize exception."""
super().__init__(None, logger)
class StoreJobError(StoreError, JobException):
"""Raise on job error with git."""
@@ -1029,18 +739,6 @@ class StoreInvalidAddonRepo(StoreError):
"""Raise on invalid addon repo."""
class StoreRepositoryUnknownError(StoreError, APIUnknownSupervisorError):
"""Raise when unknown error occurs taking an action for a store repository."""
error_key = "store_repository_unknown_error"
message_template = "An unknown error occurred with addon repository {repo}"
def __init__(self, logger: Callable[..., None] | None = None, *, repo: str) -> None:
"""Initialize exception."""
self.extra_fields = {"repo": repo}
super().__init__(logger)
# Backup
@@ -1068,7 +766,7 @@ class BackupJobError(BackupError, JobException):
"""Raise on Backup job error."""
class BackupFileNotFoundError(BackupError, APINotFound):
class BackupFileNotFoundError(BackupError):
"""Raise if the backup file hasn't been found."""
@@ -1080,55 +778,6 @@ class BackupFileExistError(BackupError):
"""Raise if the backup file already exists."""
class AddonBackupMetadataInvalidError(BackupError, APIError):
"""Raise if invalid metadata file provided for addon in backup."""
error_key = "addon_backup_metadata_invalid_error"
message_template = (
"Metadata file for add-on {addon} in backup is invalid: {validation_error}"
)
def __init__(
self,
logger: Callable[..., None] | None = None,
*,
addon: str,
validation_error: str,
) -> None:
"""Initialize exception."""
self.extra_fields = {"addon": addon, "validation_error": validation_error}
super().__init__(None, logger)
class AddonPrePostBackupCommandReturnedError(BackupError, APIError):
"""Raise when addon's pre/post backup command returns an error."""
error_key = "addon_pre_post_backup_command_returned_error"
message_template = (
"Pre-/Post backup command for add-on {addon} returned error code: "
"{exit_code}. Please report this to the addon developer. Enable debug "
"logging to capture complete command output using {debug_logging_command}"
)
def __init__(
self, logger: Callable[..., None] | None = None, *, addon: str, exit_code: int
) -> None:
"""Initialize exception."""
self.extra_fields = {
"addon": addon,
"exit_code": exit_code,
"debug_logging_command": "ha supervisor options --logging debug",
}
super().__init__(None, logger)
class BackupRestoreUnknownError(BackupError, APIUnknownSupervisorError):
"""Raise when an unknown error occurs during backup or restore."""
error_key = "backup_restore_unknown_error"
message_template = "An unknown error occurred during backup/restore"
# Security

View File

@@ -6,6 +6,8 @@ from pathlib import Path
import shutil
from typing import Any
from supervisor.resolution.const import UnhealthyReason
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import (
DBusError,
@@ -13,7 +15,6 @@ from ..exceptions import (
DBusObjectError,
HardwareNotFound,
)
from ..resolution.const import UnhealthyReason
from .const import UdevSubsystem
from .data import Device

View File

@@ -135,7 +135,6 @@ class HomeAssistantAPI(CoreSysAttributes):
"""
url = f"{self.sys_homeassistant.api_url}/{path}"
headers = headers or {}
client_timeout = aiohttp.ClientTimeout(total=timeout)
# Passthrough content type
if content_type is not None:
@@ -145,11 +144,10 @@ class HomeAssistantAPI(CoreSysAttributes):
try:
await self.ensure_access_token()
headers[hdrs.AUTHORIZATION] = f"Bearer {self.access_token}"
async with self.sys_websession.request(
method,
async with getattr(self.sys_websession, method)(
url,
data=data,
timeout=client_timeout,
timeout=timeout,
json=json,
headers=headers,
params=params,
@@ -177,10 +175,7 @@ class HomeAssistantAPI(CoreSysAttributes):
async def get_config(self) -> dict[str, Any]:
"""Return Home Assistant config."""
config = await self._get_json("api/config")
if config is None or not isinstance(config, dict):
raise HomeAssistantAPIError("No config received from Home Assistant API")
return config
return await self._get_json("api/config")
async def get_core_state(self) -> dict[str, Any]:
"""Return Home Assistant core state."""
@@ -224,32 +219,3 @@ class HomeAssistantAPI(CoreSysAttributes):
if state := await self.get_api_state():
return state.core_state == "RUNNING" or state.offline_db_migration
return False
async def check_frontend_available(self) -> bool:
"""Check if the frontend is accessible by fetching the root path.
Caller should make sure that Home Assistant Core is running before
calling this method.
Returns:
True if the frontend responds successfully, False otherwise.
"""
try:
async with self.make_request("get", "", timeout=30) as resp:
# Frontend should return HTML content
if resp.status == 200:
content_type = resp.headers.get(hdrs.CONTENT_TYPE, "")
if "text/html" in content_type:
_LOGGER.debug("Frontend is accessible and serving HTML")
return True
_LOGGER.warning(
"Frontend responded but with unexpected content type: %s",
content_type,
)
return False
_LOGGER.warning("Frontend returned status %s", resp.status)
return False
except HomeAssistantAPIError as err:
_LOGGER.debug("Cannot reach frontend: %s", err)
return False

View File

@@ -13,10 +13,7 @@ from typing import Final
from awesomeversion import AwesomeVersion
from supervisor.utils import remove_colors
from ..bus import EventListener
from ..const import ATTR_HOMEASSISTANT, BusEvent, CoreState
from ..const import ATTR_HOMEASSISTANT, BusEvent
from ..coresys import CoreSys
from ..docker.const import ContainerState
from ..docker.homeassistant import DockerHomeAssistant
@@ -36,6 +33,7 @@ from ..jobs.const import JOB_GROUP_HOME_ASSISTANT_CORE, JobConcurrency, JobThrot
from ..jobs.decorator import Job, JobCondition
from ..jobs.job_group import JobGroup
from ..resolution.const import ContextType, IssueType
from ..utils import convert_to_ascii
from ..utils.sentry import async_capture_exception
from .const import (
LANDINGPAGE,
@@ -50,7 +48,7 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
SECONDS_BETWEEN_API_CHECKS: Final[int] = 5
# Core Stage 1 and some wiggle room
STARTUP_API_RESPONSE_TIMEOUT: Final[timedelta] = timedelta(minutes=10)
STARTUP_API_RESPONSE_TIMEOUT: Final[timedelta] = timedelta(minutes=3)
# All stages plus event start timeout and some wiggle rooom
STARTUP_API_CHECK_RUNNING_TIMEOUT: Final[timedelta] = timedelta(minutes=15)
# While database migration is running, the timeout will be extended
@@ -76,7 +74,6 @@ class HomeAssistantCore(JobGroup):
super().__init__(coresys, JOB_GROUP_HOME_ASSISTANT_CORE)
self.instance: DockerHomeAssistant = DockerHomeAssistant(coresys)
self._error_state: bool = False
self._watchdog_listener: EventListener | None = None
@property
def error_state(self) -> bool:
@@ -85,11 +82,8 @@ class HomeAssistantCore(JobGroup):
async def load(self) -> None:
"""Prepare Home Assistant object."""
self._watchdog_listener = self.sys_bus.register_event(
BusEvent.DOCKER_CONTAINER_STATE_CHANGE, self.watchdog_container
)
self.sys_bus.register_event(
BusEvent.SUPERVISOR_STATE_CHANGE, self._supervisor_state_changed
BusEvent.DOCKER_CONTAINER_STATE_CHANGE, self.watchdog_container
)
try:
@@ -182,53 +176,28 @@ class HomeAssistantCore(JobGroup):
concurrency=JobConcurrency.GROUP_REJECT,
)
async def install(self) -> None:
"""Install Home Assistant Core."""
"""Install a landing page."""
_LOGGER.info("Home Assistant setup")
stop_progress_log = asyncio.Event()
while True:
# read homeassistant tag and install it
if not self.sys_homeassistant.latest_version:
await self.sys_updater.reload()
async def _periodic_progress_log() -> None:
"""Log installation progress periodically for user visibility."""
while not stop_progress_log.is_set():
if to_version := self.sys_homeassistant.latest_version:
try:
await asyncio.wait_for(stop_progress_log.wait(), timeout=15)
except TimeoutError:
if (job := self.instance.active_job) and job.progress:
_LOGGER.info(
"Downloading Home Assistant Core image, %d%%",
int(job.progress),
)
else:
_LOGGER.info("Home Assistant Core installation in progress")
await self.instance.update(
to_version,
image=self.sys_updater.image_homeassistant,
)
self.sys_homeassistant.version = self.instance.version or to_version
break
except (DockerError, JobException):
pass
except Exception as err: # pylint: disable=broad-except
await async_capture_exception(err)
progress_task = self.sys_create_task(_periodic_progress_log())
try:
while True:
# read homeassistant tag and install it
if not self.sys_homeassistant.latest_version:
await self.sys_updater.reload()
if to_version := self.sys_homeassistant.latest_version:
try:
await self.instance.update(
to_version,
image=self.sys_updater.image_homeassistant,
)
self.sys_homeassistant.version = (
self.instance.version or to_version
)
break
except (DockerError, JobException):
pass
except Exception as err: # pylint: disable=broad-except
await async_capture_exception(err)
_LOGGER.warning(
"Error on Home Assistant installation. Retrying in 30sec"
)
await asyncio.sleep(30)
finally:
stop_progress_log.set()
await progress_task
_LOGGER.warning("Error on Home Assistant installation. Retrying in 30sec")
await asyncio.sleep(30)
_LOGGER.info("Home Assistant docker now installed")
self.sys_homeassistant.set_image(self.sys_updater.image_homeassistant)
@@ -334,18 +303,12 @@ class HomeAssistantCore(JobGroup):
except HomeAssistantError:
# The API stoped responding between the up checks an now
self._error_state = True
return
data = None
# Verify that the frontend is loaded
if "frontend" not in data.get("components", []):
if data and "frontend" not in data.get("components", []):
_LOGGER.error("API responds but frontend is not loaded")
self._error_state = True
# Check that the frontend is actually accessible
elif not await self.sys_homeassistant.api.check_frontend_available():
_LOGGER.error(
"Frontend component loaded but frontend is not accessible"
)
self._error_state = True
else:
return
@@ -358,12 +321,12 @@ class HomeAssistantCore(JobGroup):
# Make a copy of the current log file if it exists
logfile = self.sys_config.path_homeassistant / "home-assistant.log"
if await self.sys_run_in_executor(logfile.exists):
if logfile.exists():
rollback_log = (
self.sys_config.path_homeassistant / "home-assistant-rollback.log"
)
await self.sys_run_in_executor(shutil.copy, logfile, rollback_log)
shutil.copy(logfile, rollback_log)
_LOGGER.info(
"A backup of the logfile is stored in /config/home-assistant-rollback.log"
)
@@ -458,6 +421,13 @@ class HomeAssistantCore(JobGroup):
await self.instance.stop()
await self.start()
def logs(self) -> Awaitable[bytes]:
"""Get HomeAssistant docker logs.
Return a coroutine.
"""
return self.instance.logs()
async def stats(self) -> DockerStats:
"""Return stats of Home Assistant."""
try:
@@ -488,15 +458,7 @@ class HomeAssistantCore(JobGroup):
"""Run Home Assistant config check."""
try:
result = await self.instance.execute_command(
[
"python3",
"-m",
"homeassistant",
"-c",
"/config",
"--script",
"check_config",
]
"python3 -m homeassistant -c /config --script check_config"
)
except DockerError as err:
raise HomeAssistantError() from err
@@ -506,7 +468,7 @@ class HomeAssistantCore(JobGroup):
raise HomeAssistantError("Fatal error on config check!", _LOGGER.error)
# Convert output
log = remove_colors("\n".join(result.log))
log = convert_to_ascii(result.output)
_LOGGER.debug("Result config check: %s", log.strip())
# Parse output
@@ -588,16 +550,6 @@ class HomeAssistantCore(JobGroup):
if event.state in [ContainerState.FAILED, ContainerState.UNHEALTHY]:
await self._restart_after_problem(event.state)
async def _supervisor_state_changed(self, state: CoreState) -> None:
"""Handle supervisor state changes to disable watchdog during shutdown."""
if state in (CoreState.SHUTDOWN, CoreState.STOPPING, CoreState.CLOSE):
if self._watchdog_listener:
_LOGGER.debug(
"Unregistering Home Assistant watchdog due to system shutdown"
)
self.sys_bus.remove_listener(self._watchdog_listener)
self._watchdog_listener = None
@Job(
name="home_assistant_core_restart_after_problem",
throttle_period=WATCHDOG_THROTTLE_PERIOD,

View File

@@ -13,7 +13,7 @@ from typing import Any
from uuid import UUID
from awesomeversion import AwesomeVersion, AwesomeVersionException
from securetar import AddFileError, SecureTarFile, atomic_contents_add, secure_path
from securetar import AddFileError, atomic_contents_add, secure_path
import voluptuous as vol
from voluptuous.humanize import humanize_error
@@ -23,7 +23,6 @@ from ..const import (
ATTR_AUDIO_OUTPUT,
ATTR_BACKUPS_EXCLUDE_DATABASE,
ATTR_BOOT,
ATTR_DUPLICATE_LOG_FILE,
ATTR_IMAGE,
ATTR_MESSAGE,
ATTR_PORT,
@@ -75,7 +74,6 @@ HOMEASSISTANT_BACKUP_EXCLUDE = [
"backups/*.tar",
"tmp_backups/*.tar",
"tts/*",
".cache/*",
]
HOMEASSISTANT_BACKUP_EXCLUDE_DATABASE = [
"home-assistant_v?.db",
@@ -301,16 +299,6 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
"""Set whether backups should exclude database by default."""
self._data[ATTR_BACKUPS_EXCLUDE_DATABASE] = value
@property
def duplicate_log_file(self) -> bool:
"""Return True if Home Assistant should duplicate logs to file."""
return self._data[ATTR_DUPLICATE_LOG_FILE]
@duplicate_log_file.setter
def duplicate_log_file(self, value: bool) -> None:
"""Set whether Home Assistant should duplicate logs to file."""
self._data[ATTR_DUPLICATE_LOG_FILE] = value
async def load(self) -> None:
"""Prepare Home Assistant object."""
await asyncio.wait(
@@ -360,23 +348,15 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
):
return
try:
configuration: (
dict[str, Any] | None
) = await self.sys_homeassistant.websocket.async_send_command(
{ATTR_TYPE: "get_config"}
)
except HomeAssistantWSError as err:
_LOGGER.warning(
"Can't get Home Assistant Core configuration: %s. Not sending hardware events to Home Assistant Core.",
err,
)
return
configuration: (
dict[str, Any] | None
) = await self.sys_homeassistant.websocket.async_send_command(
{ATTR_TYPE: "get_config"}
)
if not configuration or "usb" not in configuration.get("components", []):
return
self.sys_homeassistant.websocket.send_command({ATTR_TYPE: "usb/scan"})
self.sys_homeassistant.websocket.send_message({ATTR_TYPE: "usb/scan"})
@Job(name="home_assistant_module_begin_backup")
async def begin_backup(self) -> None:
@@ -418,7 +398,7 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
@Job(name="home_assistant_module_backup")
async def backup(
self, tar_file: SecureTarFile, exclude_database: bool = False
self, tar_file: tarfile.TarFile, exclude_database: bool = False
) -> None:
"""Backup Home Assistant Core config/directory."""
excludes = HOMEASSISTANT_BACKUP_EXCLUDE.copy()
@@ -478,7 +458,7 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
@Job(name="home_assistant_module_restore")
async def restore(
self, tar_file: SecureTarFile, exclude_database: bool | None = False
self, tar_file: tarfile.TarFile, exclude_database: bool = False
) -> None:
"""Restore Home Assistant Core config/ directory."""
@@ -510,14 +490,14 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
temp_data = temp_path
_LOGGER.info("Restore Home Assistant Core config folder")
if exclude_database is True:
if exclude_database:
remove_folder_with_excludes(
self.sys_config.path_homeassistant,
excludes=HOMEASSISTANT_BACKUP_EXCLUDE_DATABASE,
tmp_dir=self.sys_config.path_tmp,
)
else:
remove_folder(self.sys_config.path_homeassistant, content_only=True)
remove_folder(self.sys_config.path_homeassistant)
try:
shutil.copytree(

View File

@@ -10,7 +10,6 @@ from ..const import (
ATTR_AUDIO_OUTPUT,
ATTR_BACKUPS_EXCLUDE_DATABASE,
ATTR_BOOT,
ATTR_DUPLICATE_LOG_FILE,
ATTR_IMAGE,
ATTR_PORT,
ATTR_REFRESH_TOKEN,
@@ -37,7 +36,6 @@ SCHEMA_HASS_CONFIG = vol.Schema(
vol.Optional(ATTR_AUDIO_OUTPUT, default=None): vol.Maybe(str),
vol.Optional(ATTR_AUDIO_INPUT, default=None): vol.Maybe(str),
vol.Optional(ATTR_BACKUPS_EXCLUDE_DATABASE, default=False): vol.Boolean(),
vol.Optional(ATTR_DUPLICATE_LOG_FILE, default=False): vol.Boolean(),
vol.Optional(ATTR_OVERRIDE_IMAGE, default=False): vol.Boolean(),
},
extra=vol.REMOVE_EXTRA,

View File

@@ -30,6 +30,12 @@ from ..exceptions import (
from ..utils.json import json_dumps
from .const import CLOSING_STATES, WSEvent, WSType
MIN_VERSION = {
WSType.SUPERVISOR_EVENT: "2021.2.4",
WSType.BACKUP_START: "2022.1.0",
WSType.BACKUP_END: "2022.1.0",
}
_LOGGER: logging.Logger = logging.getLogger(__name__)
T = TypeVar("T")
@@ -40,6 +46,7 @@ class WSClient:
def __init__(
self,
loop: asyncio.BaseEventLoop,
ha_version: AwesomeVersion,
client: aiohttp.ClientWebSocketResponse,
):
@@ -47,6 +54,7 @@ class WSClient:
self.ha_version = ha_version
self._client = client
self._message_id: int = 0
self._loop = loop
self._futures: dict[int, asyncio.Future[T]] = {} # type: ignore
@property
@@ -65,11 +73,20 @@ class WSClient:
if not self._client.closed:
await self._client.close()
async def async_send_message(self, message: dict[str, Any]) -> None:
"""Send a websocket message, don't wait for response."""
self._message_id += 1
_LOGGER.debug("Sending: %s", message)
try:
await self._client.send_json(message, dumps=json_dumps)
except ConnectionError as err:
raise HomeAssistantWSConnectionError(str(err)) from err
async def async_send_command(self, message: dict[str, Any]) -> T | None:
"""Send a websocket message, and return the response."""
self._message_id += 1
message["id"] = self._message_id
self._futures[message["id"]] = asyncio.get_running_loop().create_future()
self._futures[message["id"]] = self._loop.create_future()
_LOGGER.debug("Sending: %s", message)
try:
await self._client.send_json(message, dumps=json_dumps)
@@ -140,7 +157,7 @@ class WSClient:
@classmethod
async def connect_with_auth(
cls, session: aiohttp.ClientSession, url: str, token: str
cls, session: aiohttp.ClientSession, loop, url: str, token: str
) -> WSClient:
"""Create an authenticated websocket client."""
try:
@@ -159,7 +176,7 @@ class WSClient:
if auth_ok_message[ATTR_TYPE] != "auth_ok":
raise HomeAssistantAPIError("AUTH NOT OK")
return cls(AwesomeVersion(hello_message["ha_version"]), client)
return cls(loop, AwesomeVersion(hello_message["ha_version"]), client)
class HomeAssistantWebSocket(CoreSysAttributes):
@@ -176,7 +193,7 @@ class HomeAssistantWebSocket(CoreSysAttributes):
"""Process queue once supervisor is running."""
if reference == CoreState.RUNNING:
for msg in self._queue:
await self._async_send_command(msg)
await self.async_send_message(msg)
self._queue.clear()
@@ -190,6 +207,7 @@ class HomeAssistantWebSocket(CoreSysAttributes):
await self.sys_homeassistant.api.ensure_access_token()
client = await WSClient.connect_with_auth(
self.sys_websession,
self.sys_loop,
self.sys_homeassistant.ws_url,
cast(str, self.sys_homeassistant.api.access_token),
)
@@ -197,88 +215,92 @@ class HomeAssistantWebSocket(CoreSysAttributes):
self.sys_create_task(client.start_listener())
return client
async def _ensure_connected(self) -> None:
"""Ensure WebSocket connection is ready.
Raises HomeAssistantWSError if unable to connect.
"""
async def _can_send(self, message: dict[str, Any]) -> bool:
"""Determine if we can use WebSocket messages."""
if self.sys_core.state in CLOSING_STATES:
raise HomeAssistantWSError(
"WebSocket not available, system is shutting down"
)
return False
connected = self._client and self._client.connected
# If we are already connected, we can avoid the check_api_state call
# since it makes a new socket connection and we already have one.
if not connected and not await self.sys_homeassistant.api.check_api_state():
raise HomeAssistantWSError(
"Can't connect to Home Assistant Core WebSocket, the API is not reachable"
)
# No core access, don't try.
return False
if not self._client or not self._client.connected:
if not self._client:
self._client = await self._get_ws_client()
if not self._client.connected:
self._client = await self._get_ws_client()
message_type = message.get("type")
if (
message_type is not None
and message_type in MIN_VERSION
and self._client.ha_version < MIN_VERSION[message_type]
):
_LOGGER.info(
"WebSocket command %s is not supported until core-%s. Ignoring WebSocket message.",
message_type,
MIN_VERSION[message_type],
)
return False
return True
async def load(self) -> None:
"""Set up queue processor after startup completes."""
self.sys_bus.register_event(
BusEvent.SUPERVISOR_STATE_CHANGE, self._process_queue
)
async def _async_send_command(self, message: dict[str, Any]) -> None:
"""Send a fire-and-forget command via WebSocket.
Queues messages during startup. Silently handles connection errors.
"""
async def async_send_message(self, message: dict[str, Any]) -> None:
"""Send a message with the WS client."""
# Only commands allowed during startup as those tell Home Assistant to do something.
# Messages may cause clients to make follow-up API calls so those wait.
if self.sys_core.state in STARTING_STATES:
self._queue.append(message)
_LOGGER.debug("Queuing message until startup has completed: %s", message)
return
try:
await self._ensure_connected()
except HomeAssistantWSError as err:
_LOGGER.debug("Can't send WebSocket command: %s", err)
if not await self._can_send(message):
return
# _ensure_connected guarantees self._client is set
assert self._client
try:
await self._client.async_send_command(message)
except HomeAssistantWSConnectionError as err:
_LOGGER.debug("Fire-and-forget WebSocket command failed: %s", err)
if self._client:
await self._client.async_send_command(message)
except HomeAssistantWSConnectionError:
if self._client:
await self._client.close()
self._client = None
async def async_send_command(self, message: dict[str, Any]) -> T | None:
"""Send a command and return the response.
"""Send a command with the WS client and wait for the response."""
if not await self._can_send(message):
return None
Raises HomeAssistantWSError if unable to connect to Home Assistant Core.
"""
await self._ensure_connected()
# _ensure_connected guarantees self._client is set
assert self._client
try:
return await self._client.async_send_command(message)
if self._client:
return await self._client.async_send_command(message)
except HomeAssistantWSConnectionError:
if self._client:
await self._client.close()
self._client = None
raise
return None
def send_command(self, message: dict[str, Any]) -> None:
"""Send a fire-and-forget command via WebSocket."""
def send_message(self, message: dict[str, Any]) -> None:
"""Send a supervisor/event message."""
if self.sys_core.state in CLOSING_STATES:
return
self.sys_create_task(self._async_send_command(message))
self.sys_create_task(self.async_send_message(message))
async def async_supervisor_event_custom(
self, event: WSEvent, extra_data: dict[str, Any] | None = None
) -> None:
"""Send a supervisor/event message to Home Assistant with custom data."""
try:
await self._async_send_command(
await self.async_send_message(
{
ATTR_TYPE: WSType.SUPERVISOR_EVENT,
ATTR_DATA: {

View File

@@ -6,8 +6,8 @@ import logging
import socket
from ..dbus.const import (
ConnectionState,
ConnectionStateFlags,
ConnectionStateType,
DeviceType,
InterfaceAddrGenMode as NMInterfaceAddrGenMode,
InterfaceIp6Privacy as NMInterfaceIp6Privacy,
@@ -64,7 +64,6 @@ class IpSetting:
method: InterfaceMethod
address: list[IPv4Interface | IPv6Interface]
gateway: IPv4Address | IPv6Address | None
route_metric: int | None
nameservers: list[IPv4Address | IPv6Address]
@@ -167,7 +166,6 @@ class Interface:
gateway=IPv4Address(inet.settings.ipv4.gateway)
if inet.settings.ipv4.gateway
else None,
route_metric=inet.settings.ipv4.route_metric,
nameservers=[
IPv4Address(socket.ntohl(ip)) for ip in inet.settings.ipv4.dns
]
@@ -175,7 +173,7 @@ class Interface:
else [],
)
else:
ipv4_setting = IpSetting(InterfaceMethod.DISABLED, [], None, None, [])
ipv4_setting = IpSetting(InterfaceMethod.DISABLED, [], None, [])
if inet.settings and inet.settings.ipv6:
ipv6_setting = Ip6Setting(
@@ -195,13 +193,12 @@ class Interface:
gateway=IPv6Address(inet.settings.ipv6.gateway)
if inet.settings.ipv6.gateway
else None,
route_metric=inet.settings.ipv6.route_metric,
nameservers=[IPv6Address(bytes(ip)) for ip in inet.settings.ipv6.dns]
if inet.settings.ipv6.dns
else [],
)
else:
ipv6_setting = Ip6Setting(InterfaceMethod.DISABLED, [], None, None, [])
ipv6_setting = Ip6Setting(InterfaceMethod.DISABLED, [], None, [])
ipv4_ready = (
inet.connection is not None
@@ -270,47 +267,25 @@ class Interface:
return InterfaceMethod.DISABLED
@staticmethod
def _map_nm_addr_gen_mode(addr_gen_mode: int | None) -> InterfaceAddrGenMode:
"""Map IPv6 interface addr_gen_mode.
NetworkManager omits the addr_gen_mode property when set to DEFAULT, so we
treat None as DEFAULT here.
"""
def _map_nm_addr_gen_mode(addr_gen_mode: int) -> InterfaceAddrGenMode:
"""Map IPv6 interface addr_gen_mode."""
mapping = {
NMInterfaceAddrGenMode.EUI64.value: InterfaceAddrGenMode.EUI64,
NMInterfaceAddrGenMode.STABLE_PRIVACY.value: InterfaceAddrGenMode.STABLE_PRIVACY,
NMInterfaceAddrGenMode.DEFAULT_OR_EUI64.value: InterfaceAddrGenMode.DEFAULT_OR_EUI64,
NMInterfaceAddrGenMode.DEFAULT.value: InterfaceAddrGenMode.DEFAULT,
None: InterfaceAddrGenMode.DEFAULT,
}
if addr_gen_mode not in mapping:
_LOGGER.warning(
"Unknown addr_gen_mode value from NetworkManager: %s", addr_gen_mode
)
return mapping.get(addr_gen_mode, InterfaceAddrGenMode.DEFAULT)
@staticmethod
def _map_nm_ip6_privacy(ip6_privacy: int | None) -> InterfaceIp6Privacy:
"""Map IPv6 interface ip6_privacy.
NetworkManager omits the ip6_privacy property when set to DEFAULT, so we
treat None as DEFAULT here.
"""
def _map_nm_ip6_privacy(ip6_privacy: int) -> InterfaceIp6Privacy:
"""Map IPv6 interface ip6_privacy."""
mapping = {
NMInterfaceIp6Privacy.DISABLED.value: InterfaceIp6Privacy.DISABLED,
NMInterfaceIp6Privacy.ENABLED_PREFER_PUBLIC.value: InterfaceIp6Privacy.ENABLED_PREFER_PUBLIC,
NMInterfaceIp6Privacy.ENABLED.value: InterfaceIp6Privacy.ENABLED,
NMInterfaceIp6Privacy.DEFAULT.value: InterfaceIp6Privacy.DEFAULT,
None: InterfaceIp6Privacy.DEFAULT,
}
if ip6_privacy not in mapping:
_LOGGER.warning(
"Unknown ip6_privacy value from NetworkManager: %s", ip6_privacy
)
return mapping.get(ip6_privacy, InterfaceIp6Privacy.DEFAULT)
@staticmethod
@@ -320,8 +295,8 @@ class Interface:
return False
return connection.state in (
ConnectionState.ACTIVATED,
ConnectionState.ACTIVATING,
ConnectionStateType.ACTIVATED,
ConnectionStateType.ACTIVATING,
)
@staticmethod

View File

@@ -5,6 +5,8 @@ from contextlib import suppress
import logging
from typing import Any
from supervisor.utils.sentry import async_capture_exception
from ..const import ATTR_HOST_INTERNET
from ..coresys import CoreSys, CoreSysAttributes
from ..dbus.const import (
@@ -14,7 +16,7 @@ from ..dbus.const import (
DBUS_IFACE_DNS,
DBUS_IFACE_NM,
DBUS_SIGNAL_NM_CONNECTION_ACTIVE_CHANGED,
ConnectionState,
ConnectionStateType,
ConnectivityState,
DeviceType,
WirelessMethodType,
@@ -32,7 +34,6 @@ from ..exceptions import (
from ..jobs.const import JobCondition
from ..jobs.decorator import Job
from ..resolution.checks.network_interface_ipv4 import CheckNetworkInterfaceIPV4
from ..utils.sentry import async_capture_exception
from .configuration import AccessPoint, Interface
from .const import InterfaceMethod, WifiMode
@@ -337,16 +338,16 @@ class NetworkManager(CoreSysAttributes):
# the state change before this point. Get the state currently to
# avoid any race condition.
await con.update()
state: ConnectionState = con.state
state: ConnectionStateType = con.state
while state != ConnectionState.ACTIVATED:
if state == ConnectionState.DEACTIVATED:
while state != ConnectionStateType.ACTIVATED:
if state == ConnectionStateType.DEACTIVATED:
raise HostNetworkError(
"Activating connection failed, check connection settings."
)
msg = await signal.wait_for_signal()
state = ConnectionState(msg[0])
state = msg[0]
_LOGGER.debug("Active connection state changed to %s", state)
# update_only means not done by user so don't force a check afterwards

View File

@@ -102,17 +102,13 @@ class SupervisorJobError:
"Unknown error, see Supervisor logs (check with 'ha supervisor logs')"
)
stage: str | None = None
error_key: str | None = None
extra_fields: dict[str, Any] | None = None
def as_dict(self) -> dict[str, Any]:
def as_dict(self) -> dict[str, str | None]:
"""Return dictionary representation."""
return {
"type": self.type_.__name__,
"message": self.message,
"stage": self.stage,
"error_key": self.error_key,
"extra_fields": self.extra_fields,
}
@@ -162,9 +158,7 @@ class SupervisorJob:
def capture_error(self, err: HassioError | None = None) -> None:
"""Capture an error or record that an unknown error has occurred."""
if err:
new_error = SupervisorJobError(
type(err), str(err), self.stage, err.error_key, err.extra_fields
)
new_error = SupervisorJobError(type(err), str(err), self.stage)
else:
new_error = SupervisorJobError(stage=self.stage)
self.errors += [new_error]

View File

@@ -72,9 +72,6 @@ def filter_data(coresys: CoreSys, event: Event, hint: Hint) -> Event | None:
"docker": coresys.docker.info.version,
"supervisor": coresys.supervisor.version,
},
"docker": {
"storage_driver": coresys.docker.info.storage,
},
"host": {
"machine": coresys.machine,
},
@@ -96,7 +93,7 @@ def filter_data(coresys: CoreSys, event: Event, hint: Hint) -> Event | None:
"installed_addons": installed_addons,
},
"host": {
"arch": str(coresys.arch.default),
"arch": coresys.arch.default,
"board": coresys.os.board,
"deployment": coresys.host.info.deployment,
"disk_free_space": coresys.hardware.disk.get_disk_free_space(
@@ -114,9 +111,6 @@ def filter_data(coresys: CoreSys, event: Event, hint: Hint) -> Event | None:
"docker": coresys.docker.info.version,
"supervisor": coresys.supervisor.version,
},
"docker": {
"storage_driver": coresys.docker.info.storage,
},
"resolution": {
"issues": [attr.asdict(issue) for issue in coresys.resolution.issues],
"suggestions": [

View File

@@ -13,7 +13,6 @@ from ..exceptions import (
AddonsError,
BackupFileNotFoundError,
HomeAssistantError,
HomeAssistantWSError,
ObserverError,
SupervisorUpdateError,
)
@@ -153,13 +152,7 @@ class Tasks(CoreSysAttributes):
"Sending update add-on WebSocket command to Home Assistant Core: %s",
message,
)
try:
await self.sys_homeassistant.websocket.async_send_command(message)
except HomeAssistantWSError as err:
_LOGGER.warning(
"Could not send add-on update command to Home Assistant Core: %s",
err,
)
await self.sys_homeassistant.websocket.async_send_command(message)
@Job(
name="tasks_update_supervisor",

View File

@@ -76,6 +76,13 @@ class PluginBase(ABC, FileConfiguration, CoreSysAttributes):
"""Return True if a task is in progress."""
return self.instance.in_progress
def logs(self) -> Awaitable[bytes]:
"""Get docker plugin logs.
Return Coroutine.
"""
return self.instance.logs()
def is_running(self) -> Awaitable[bool]:
"""Return True if Docker container is running.

View File

@@ -368,7 +368,7 @@ class PluginDns(PluginBase):
log = await self.instance.logs()
# Check the log for loop plugin output
if any("plugin/loop: Loop" in line for line in log):
if b"plugin/loop: Loop" in log:
_LOGGER.error("Detected a DNS loop in local Network!")
self._loop = True
self.sys_resolution.create_issue(

View File

@@ -15,11 +15,9 @@ from ..docker.const import ContainerState
from ..docker.observer import DockerObserver
from ..docker.stats import DockerStats
from ..exceptions import (
DockerContainerPortConflict,
DockerError,
ObserverError,
ObserverJobError,
ObserverPortConflict,
ObserverUpdateError,
PluginError,
)
@@ -89,8 +87,6 @@ class PluginObserver(PluginBase):
_LOGGER.info("Starting observer plugin")
try:
await self.instance.run()
except DockerContainerPortConflict as err:
raise ObserverPortConflict(_LOGGER.error) from err
except DockerError as err:
_LOGGER.error("Can't start observer plugin")
raise ObserverError() from err

View File

@@ -22,8 +22,7 @@ async def check_server(
"""Check a DNS server and report issues."""
ip_addr = server[6:] if server.startswith("dns://") else server
async with DNSResolver(loop=loop, nameservers=[ip_addr]) as resolver:
# following call should be changed to resolver.query() in aiodns 5.x
await resolver.query_dns(DNS_CHECK_HOST, qtype)
await resolver.query(DNS_CHECK_HOST, qtype)
def setup(coresys: CoreSys) -> CheckBase:

View File

@@ -2,7 +2,7 @@
from ...const import CoreState
from ...coresys import CoreSys
from ...dbus.const import ConnectionState, ConnectionStateFlags
from ...dbus.const import ConnectionStateFlags, ConnectionStateType
from ...dbus.network.interface import NetworkInterface
from ...exceptions import NetworkInterfaceNotFound
from ..const import ContextType, IssueType
@@ -47,7 +47,7 @@ class CheckNetworkInterfaceIPV4(CheckBase):
return not (
interface.connection.state
in [ConnectionState.ACTIVATED, ConnectionState.ACTIVATING]
in [ConnectionStateType.ACTIVATED, ConnectionStateType.ACTIVATING]
and ConnectionStateFlags.IP4_READY in interface.connection.state_flags
)

View File

@@ -1,13 +1,14 @@
"""Evaluation class for container."""
import asyncio
import logging
import aiodocker
from docker.errors import DockerException
from requests import RequestException
from supervisor.docker.const import ADDON_BUILDER_IMAGE
from ...const import CoreState
from ...coresys import CoreSys
from ...docker.const import ADDON_BUILDER_IMAGE
from ..const import (
ContextType,
IssueType,
@@ -73,9 +74,8 @@ class EvaluateContainer(EvaluateBase):
self._images.clear()
try:
containers = await self.sys_docker.containers.list()
containers_metadata = await asyncio.gather(*[c.show() for c in containers])
except aiodocker.DockerError as err:
containers = await self.sys_run_in_executor(self.sys_docker.containers.list)
except (DockerException, RequestException) as err:
_LOGGER.error("Corrupt docker overlayfs detect: %s", err)
self.sys_resolution.create_issue(
IssueType.CORRUPT_DOCKER,
@@ -86,8 +86,8 @@ class EvaluateContainer(EvaluateBase):
images = {
image
for container in containers_metadata
if (config := container.get("Config")) is not None
for container in containers
if (config := container.attrs.get("Config")) is not None
and (image := config.get("Image")) is not None
}
for image in images:

View File

@@ -1,9 +1,10 @@
"""Evaluation class for restart policy."""
from supervisor.docker.const import RestartPolicy
from supervisor.docker.interface import DockerInterface
from ...const import CoreState
from ...coresys import CoreSys
from ...docker.const import RestartPolicy
from ...docker.interface import DockerInterface
from ..const import UnsupportedReason
from .base import EvaluateBase

View File

@@ -2,9 +2,10 @@
import logging
from supervisor.exceptions import BackupFileNotFoundError
from ...backups.const import BackupType
from ...coresys import CoreSys
from ...exceptions import BackupFileNotFoundError
from ..const import MINIMUM_FULL_BACKUPS, ContextType, IssueType, SuggestionType
from .base import FixupBase

View File

@@ -183,22 +183,19 @@ class GitRepo(CoreSysAttributes):
raise StoreGitError() from err
try:
repo = self.repo
branch = self.repo.active_branch.name
def _fetch_and_check() -> tuple[str, bool]:
"""Fetch from origin and check if changed."""
# This property access is I/O bound
branch = repo.active_branch.name
repo.remotes.origin.fetch(
**{"update-shallow": True, "depth": 1} # type: ignore[arg-type]
# Download data
await self.sys_run_in_executor(
ft.partial(
self.repo.remotes.origin.fetch,
**{"update-shallow": True, "depth": 1}, # type: ignore
)
changed = repo.commit(branch) != repo.commit(f"origin/{branch}")
return branch, changed
)
# Download data and check for changes
branch, changed = await self.sys_run_in_executor(_fetch_and_check)
if changed:
if changed := self.repo.commit(branch) != self.repo.commit(
f"origin/{branch}"
):
# Jump on top of that
await self.sys_run_in_executor(
ft.partial(self.repo.git.reset, f"origin/{branch}", hard=True)
@@ -227,7 +224,6 @@ class GitRepo(CoreSysAttributes):
git.CommandError,
ValueError,
AssertionError,
AttributeError,
UnicodeDecodeError,
) as err:
_LOGGER.error("Can't update %s repo: %s.", self.url, err)

View File

@@ -8,6 +8,8 @@ from pathlib import Path
import voluptuous as vol
from supervisor.utils import get_latest_mtime
from ..const import (
ATTR_MAINTAINER,
ATTR_NAME,
@@ -17,14 +19,7 @@ from ..const import (
REPOSITORY_LOCAL,
)
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import (
ConfigurationFileError,
StoreError,
StoreGitError,
StoreRepositoryLocalCannotReset,
StoreRepositoryUnknownError,
)
from ..utils import get_latest_mtime
from ..exceptions import ConfigurationFileError, StoreError
from ..utils.common import read_json_or_yaml_file
from .const import BuiltinRepository
from .git import GitRepo
@@ -203,12 +198,8 @@ class RepositoryGit(Repository, ABC):
async def reset(self) -> None:
"""Reset add-on repository to fix corruption issue with files."""
try:
await self._git.reset()
await self.load()
except StoreGitError as err:
_LOGGER.error("Can't reset repository %s: %s", self.slug, err)
raise StoreRepositoryUnknownError(repo=self.slug) from err
await self._git.reset()
await self.load()
class RepositoryLocal(RepositoryBuiltin):
@@ -247,7 +238,9 @@ class RepositoryLocal(RepositoryBuiltin):
async def reset(self) -> None:
"""Raise. Not supported for local repository."""
raise StoreRepositoryLocalCannotReset(_LOGGER.error)
raise StoreError(
"Can't reset local repository as it is not git based!", _LOGGER.error
)
class RepositoryGitBuiltin(RepositoryBuiltin, RepositoryGit):

View File

@@ -13,6 +13,8 @@ import aiohttp
from aiohttp.client_exceptions import ClientError
from awesomeversion import AwesomeVersion, AwesomeVersionException
from supervisor.jobs import ChildJobSyncFilter
from .const import (
ATTR_SUPERVISOR_INTERNET,
SUPERVISOR_VERSION,
@@ -26,11 +28,10 @@ from .exceptions import (
DockerError,
HostAppArmorError,
SupervisorAppArmorError,
SupervisorError,
SupervisorJobError,
SupervisorUnknownError,
SupervisorUpdateError,
)
from .jobs import ChildJobSyncFilter
from .jobs.const import JobCondition, JobThrottle
from .jobs.decorator import Job
from .resolution.const import ContextType, IssueType, UnhealthyReason
@@ -248,7 +249,7 @@ class Supervisor(CoreSysAttributes):
"""Return True if a task is in progress."""
return self.instance.in_progress
def logs(self) -> Awaitable[list[str]]:
def logs(self) -> Awaitable[bytes]:
"""Get Supervisor docker logs.
Return Coroutine.
@@ -260,7 +261,7 @@ class Supervisor(CoreSysAttributes):
try:
return await self.instance.stats()
except DockerError as err:
raise SupervisorUnknownError() from err
raise SupervisorError() from err
async def repair(self):
"""Repair local Supervisor data."""

View File

@@ -8,6 +8,8 @@ import logging
import aiohttp
from awesomeversion import AwesomeVersion
from supervisor.jobs.const import JobConcurrency, JobThrottle
from .bus import EventListener
from .const import (
ATTR_AUDIO,
@@ -30,7 +32,6 @@ from .const import (
)
from .coresys import CoreSys, CoreSysAttributes
from .exceptions import UpdaterError, UpdaterJobError
from .jobs.const import JobConcurrency, JobThrottle
from .jobs.decorator import Job, JobCondition
from .utils.common import FileConfiguration
from .validate import SCHEMA_UPDATER_CONFIG

View File

@@ -19,9 +19,9 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
RE_STRING: re.Pattern = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))")
def remove_colors(log: str) -> str:
"""Remove color characters from log."""
return RE_STRING.sub("", log)
def convert_to_ascii(raw: bytes) -> str:
"""Convert binary to ascii and remove colors."""
return RE_STRING.sub("", raw.decode())
def process_lock(method):

View File

@@ -3,7 +3,6 @@
import asyncio
from functools import partial
import logging
from typing import Literal
from aiohttp.web_exceptions import HTTPBadGateway, HTTPServiceUnavailable
import sentry_sdk
@@ -79,33 +78,6 @@ async def async_capture_exception(err: BaseException) -> None:
)
def fire_and_forget_capture_message(
msg: str,
level: Literal["fatal", "critical", "error", "warning", "info", "debug"]
| None = "warning",
) -> None:
"""Capture a message and send to sentry without blocking the event loop.
Safe to call from sync code running in the event loop. The executor future
is intentionally not awaited (fire-and-forget).
"""
if not sentry_sdk.is_initialized():
return
def _capture() -> None:
try:
sentry_sdk.capture_message(msg, level=level)
except Exception: # pylint: disable=broad-except
_LOGGER.debug("Failed to send message to Sentry: %s", msg)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
_capture()
else:
loop.run_in_executor(None, _capture)
def close_sentry() -> None:
"""Close the current sentry client.

View File

@@ -9,8 +9,8 @@ import re
from aiohttp import ClientResponse
from ..exceptions import MalformedBinaryEntryError
from ..host.const import LogFormatter
from supervisor.exceptions import MalformedBinaryEntryError
from supervisor.host.const import LogFormatter
_RE_ANSI_CSI_COLORS_PATTERN = re.compile(r"\x1B\[[0-9;]*m")

View File

@@ -4,13 +4,12 @@ import asyncio
from datetime import timedelta
import errno
from http import HTTPStatus
from pathlib import Path, PurePath
from typing import Any
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, call, patch
import aiodocker
from aiodocker.containers import DockerContainer
from awesomeversion import AwesomeVersion
from docker.errors import APIError, DockerException, NotFound
import pytest
from securetar import SecureTarFile
@@ -24,16 +23,10 @@ from supervisor.docker.addon import DockerAddon
from supervisor.docker.const import ContainerState
from supervisor.docker.manager import CommandReturn, DockerAPI
from supervisor.docker.monitor import DockerContainerStateEvent
from supervisor.exceptions import (
AddonPortConflict,
AddonPrePostBackupCommandReturnedError,
AddonsJobError,
AddonUnknownError,
AudioUpdateError,
HassioError,
)
from supervisor.exceptions import AddonsError, AddonsJobError, AudioUpdateError
from supervisor.hardware.helper import HwHelper
from supervisor.ingress import Ingress
from supervisor.store.repository import Repository
from supervisor.utils.dt import utcnow
from .test_manager import BOOT_FAIL_ISSUE, BOOT_FAIL_SUGGESTIONS
@@ -218,14 +211,18 @@ async def test_watchdog_on_stop(coresys: CoreSys, install_addon_ssh: Addon) -> N
restart.assert_called_once()
@pytest.mark.usefixtures("mock_amd64_arch_supported", "test_repository")
async def test_listener_attached_on_install(coresys: CoreSys):
async def test_listener_attached_on_install(
coresys: CoreSys, mock_amd64_arch_supported: None, test_repository
):
"""Test events listener attached on addon install."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.docker.containers.get.side_effect = aiodocker.DockerError(
500, {"message": "fail"}
)
container_collection = MagicMock()
container_collection.get.side_effect = DockerException()
with (
patch(
"supervisor.docker.manager.DockerAPI.containers",
new=PropertyMock(return_value=container_collection),
),
patch("pathlib.Path.is_dir", return_value=True),
patch(
"supervisor.addons.addon.Addon.need_build",
@@ -249,9 +246,9 @@ async def test_listener_attached_on_install(coresys: CoreSys):
@pytest.mark.parametrize(
"boot_timedelta,restart_count", [(timedelta(), 1), (timedelta(days=1), 0)]
)
@pytest.mark.usefixtures("test_repository")
async def test_watchdog_during_attach(
coresys: CoreSys,
test_repository: Repository,
boot_timedelta: timedelta,
restart_count: int,
):
@@ -283,8 +280,9 @@ async def test_watchdog_during_attach(
assert restart.call_count == restart_count
@pytest.mark.usefixtures("install_addon_ssh")
async def test_install_update_fails_if_out_of_date(coresys: CoreSys):
async def test_install_update_fails_if_out_of_date(
coresys: CoreSys, install_addon_ssh: Addon
):
"""Test install or update of addon fails when supervisor or plugin is out of date."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
@@ -337,8 +335,13 @@ async def test_listeners_removed_on_uninstall(
)
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
async def test_start(coresys: CoreSys, install_addon_ssh: Addon) -> None:
async def test_start(
coresys: CoreSys,
install_addon_ssh: Addon,
container,
tmp_supervisor_data,
path_extern,
) -> None:
"""Test starting an addon without healthcheck."""
install_addon_ssh.path_data.mkdir()
await install_addon_ssh.load()
@@ -354,16 +357,17 @@ async def test_start(coresys: CoreSys, install_addon_ssh: Addon) -> None:
@pytest.mark.parametrize("state", [ContainerState.HEALTHY, ContainerState.UNHEALTHY])
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
async def test_start_wait_healthcheck(
coresys: CoreSys,
install_addon_ssh: Addon,
container: DockerContainer,
container: MagicMock,
state: ContainerState,
tmp_supervisor_data,
path_extern,
) -> None:
"""Test starting an addon with a healthcheck waits for health status."""
install_addon_ssh.path_data.mkdir()
container.show.return_value["Config"] = {"Healthcheck": "exists"}
container.attrs["Config"] = {"Healthcheck": "exists"}
await install_addon_ssh.load()
await asyncio.sleep(0)
assert install_addon_ssh.state == AddonState.STOPPED
@@ -384,9 +388,13 @@ async def test_start_wait_healthcheck(
assert install_addon_ssh.state == AddonState.STARTED
@pytest.mark.usefixtures("coresys", "tmp_supervisor_data", "path_extern")
async def test_start_timeout(
install_addon_ssh: Addon, caplog: pytest.LogCaptureFixture
coresys: CoreSys,
install_addon_ssh: Addon,
caplog: pytest.LogCaptureFixture,
container,
tmp_supervisor_data,
path_extern,
) -> None:
"""Test starting an addon times out while waiting."""
install_addon_ssh.path_data.mkdir()
@@ -406,8 +414,13 @@ async def test_start_timeout(
assert "Timeout while waiting for addon Terminal & SSH to start" in caplog.text
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
async def test_restart(coresys: CoreSys, install_addon_ssh: Addon) -> None:
async def test_restart(
coresys: CoreSys,
install_addon_ssh: Addon,
container,
tmp_supervisor_data,
path_extern,
) -> None:
"""Test restarting an addon."""
install_addon_ssh.path_data.mkdir()
await install_addon_ssh.load()
@@ -423,16 +436,16 @@ async def test_restart(coresys: CoreSys, install_addon_ssh: Addon) -> None:
@pytest.mark.parametrize("status", ["running", "stopped"])
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
async def test_backup(
coresys: CoreSys,
install_addon_ssh: Addon,
container: DockerContainer,
container: MagicMock,
status: str,
tmp_supervisor_data,
path_extern,
) -> None:
"""Test backing up an addon."""
container.show.return_value["State"]["Status"] = status
container.show.return_value["State"]["Running"] = status == "running"
container.status = status
install_addon_ssh.path_data.mkdir()
await install_addon_ssh.load()
@@ -441,16 +454,16 @@ async def test_backup(
@pytest.mark.parametrize("status", ["running", "stopped"])
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
async def test_backup_no_config(
coresys: CoreSys,
install_addon_ssh: Addon,
container: DockerContainer,
container: MagicMock,
status: str,
tmp_supervisor_data,
path_extern,
) -> None:
"""Test backing up an addon with deleted config directory."""
container.show.return_value["State"]["Status"] = status
container.show.return_value["State"]["Running"] = status == "running"
container.status = status
install_addon_ssh.data["map"].append({"type": "addon_config", "read_only": False})
assert not install_addon_ssh.path_config.exists()
@@ -461,15 +474,16 @@ async def test_backup_no_config(
assert await install_addon_ssh.backup(tarfile) is None
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
async def test_backup_with_pre_post_command(
coresys: CoreSys,
install_addon_ssh: Addon,
container: DockerContainer,
container: MagicMock,
tmp_supervisor_data,
path_extern,
) -> None:
"""Test backing up an addon with pre and post command."""
container.show.return_value["State"]["Status"] = "running"
container.show.return_value["State"]["Running"] = True
container.status = "running"
container.exec_run.return_value = (0, None)
install_addon_ssh.path_data.mkdir()
await install_addon_ssh.load()
@@ -482,63 +496,37 @@ async def test_backup_with_pre_post_command(
):
assert await install_addon_ssh.backup(tarfile) is None
assert container.exec.call_count == 2
assert container.exec.call_args_list[0].args[0] == "backup_pre"
assert container.exec.call_args_list[1].args[0] == "backup_post"
assert container.exec_run.call_count == 2
assert container.exec_run.call_args_list[0].args[0] == "backup_pre"
assert container.exec_run.call_args_list[1].args[0] == "backup_post"
@pytest.mark.parametrize(
(
"container_get_side_effect",
"exec_start_side_effect",
"exec_inspect_side_effect",
"exc_type_raised",
),
"get_error,exception_on_exec",
[
(
aiodocker.DockerError(HTTPStatus.NOT_FOUND, {"message": "missing"}),
None,
[{"ExitCode": 1}],
AddonUnknownError,
),
(
aiodocker.DockerError(HTTPStatus.INTERNAL_SERVER_ERROR, {"message": "bad"}),
None,
[{"ExitCode": 1}],
AddonUnknownError,
),
(
None,
aiodocker.DockerError(HTTPStatus.INTERNAL_SERVER_ERROR, {"message": "bad"}),
[{"ExitCode": 1}],
AddonUnknownError,
),
(
None,
None,
aiodocker.DockerError(HTTPStatus.INTERNAL_SERVER_ERROR, {"message": "bad"}),
AddonUnknownError,
),
(None, None, [{"ExitCode": 1}], AddonPrePostBackupCommandReturnedError),
(NotFound("missing"), False),
(DockerException(), False),
(None, True),
(None, False),
],
)
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
async def test_backup_with_pre_command_error(
coresys: CoreSys,
install_addon_ssh: Addon,
container_get_side_effect: aiodocker.DockerError | None,
exec_start_side_effect: aiodocker.DockerError | None,
exec_inspect_side_effect: aiodocker.DockerError | list[dict[str, Any]] | None,
exc_type_raised: type[HassioError],
container: MagicMock,
get_error: DockerException | None,
exception_on_exec: bool,
tmp_supervisor_data,
path_extern,
) -> None:
"""Test backing up an addon with error running pre command."""
coresys.docker.containers.get.side_effect = container_get_side_effect
coresys.docker.containers.get.return_value.exec.return_value.start.side_effect = (
exec_start_side_effect
)
coresys.docker.containers.get.return_value.exec.return_value.inspect.side_effect = (
exec_inspect_side_effect
)
if get_error:
coresys.docker.containers.get.side_effect = get_error
if exception_on_exec:
container.exec_run.side_effect = DockerException()
else:
container.exec_run.return_value = (1, None)
install_addon_ssh.path_data.mkdir()
await install_addon_ssh.load()
@@ -547,7 +535,7 @@ async def test_backup_with_pre_command_error(
with (
patch.object(DockerAddon, "is_running", return_value=True),
patch.object(Addon, "backup_pre", new=PropertyMock(return_value="backup_pre")),
pytest.raises(exc_type_raised),
pytest.raises(AddonsError),
):
assert await install_addon_ssh.backup(tarfile) is None
@@ -555,16 +543,16 @@ async def test_backup_with_pre_command_error(
@pytest.mark.parametrize("status", ["running", "stopped"])
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
async def test_backup_cold_mode(
coresys: CoreSys,
install_addon_ssh: Addon,
container: DockerContainer,
container: MagicMock,
status: str,
tmp_supervisor_data,
path_extern,
) -> None:
"""Test backing up an addon in cold mode."""
container.show.return_value["State"]["Status"] = status
container.show.return_value["State"]["Running"] = status == "running"
container.status = status
install_addon_ssh.path_data.mkdir()
await install_addon_ssh.load()
@@ -584,25 +572,22 @@ async def test_backup_cold_mode(
assert bool(start_task) is (status == "running")
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
async def test_backup_cold_mode_with_watchdog(
coresys: CoreSys,
install_addon_ssh: Addon,
container: DockerContainer,
container: MagicMock,
tmp_supervisor_data,
path_extern,
):
"""Test backing up an addon in cold mode with watchdog active."""
container.show.return_value["State"]["Status"] = "running"
container.show.return_value["State"]["Running"] = True
container.status = "running"
install_addon_ssh.watchdog = True
install_addon_ssh.path_data.mkdir()
await install_addon_ssh.load()
# Clear task queue, including the event fired for running container
await asyncio.sleep(0)
# Simulate stop firing the docker event for stopped container like it normally would
async def mock_stop(*args, **kwargs):
container.show.return_value["State"]["Status"] = "stopped"
container.show.return_value["State"]["Running"] = False
container.status = "stopped"
_fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.STOPPED)
# Patching out the normal end of backup process leaves the container in a stopped state
@@ -626,10 +611,15 @@ async def test_backup_cold_mode_with_watchdog(
@pytest.mark.parametrize("status", ["running", "stopped"])
@pytest.mark.usefixtures(
"tmp_supervisor_data", "path_extern", "mock_aarch64_arch_supported"
)
async def test_restore(coresys: CoreSys, install_addon_ssh: Addon, status: str) -> None:
async def test_restore(
coresys: CoreSys,
install_addon_ssh: Addon,
container: MagicMock,
status: str,
tmp_supervisor_data,
path_extern,
mock_aarch64_arch_supported: None,
) -> None:
"""Test restoring an addon."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
install_addon_ssh.path_data.mkdir()
@@ -642,15 +632,16 @@ async def test_restore(coresys: CoreSys, install_addon_ssh: Addon, status: str)
assert bool(start_task) is (status == "running")
@pytest.mark.usefixtures(
"tmp_supervisor_data", "path_extern", "mock_aarch64_arch_supported"
)
async def test_restore_while_running(
coresys: CoreSys, install_addon_ssh: Addon, container: DockerContainer
coresys: CoreSys,
install_addon_ssh: Addon,
container: MagicMock,
tmp_supervisor_data,
path_extern,
mock_aarch64_arch_supported: None,
):
"""Test restore of a running addon."""
container.show.return_value["State"]["Status"] = "running"
container.show.return_value["State"]["Running"] = True
container.status = "running"
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
install_addon_ssh.path_data.mkdir()
await install_addon_ssh.load()
@@ -666,15 +657,16 @@ async def test_restore_while_running(
container.stop.assert_called_once()
@pytest.mark.usefixtures(
"tmp_supervisor_data", "path_extern", "mock_aarch64_arch_supported"
)
async def test_restore_while_running_with_watchdog(
coresys: CoreSys, install_addon_ssh: Addon, container: DockerContainer
coresys: CoreSys,
install_addon_ssh: Addon,
container: MagicMock,
tmp_supervisor_data,
path_extern,
mock_aarch64_arch_supported: None,
):
"""Test restore of a running addon with watchdog interference."""
container.show.return_value["State"]["Status"] = "running"
container.show.return_value["State"]["Running"] = True
container.status = "running"
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
install_addon_ssh.path_data.mkdir()
install_addon_ssh.watchdog = True
@@ -682,8 +674,7 @@ async def test_restore_while_running_with_watchdog(
# Simulate stop firing the docker event for stopped container like it normally would
async def mock_stop(*args, **kwargs):
container.show.return_value["State"]["Status"] = "stopped"
container.show.return_value["State"]["Running"] = False
container.status = "stopped"
_fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.STOPPED)
# We restore a stopped backup so restore will not restart it
@@ -701,15 +692,14 @@ async def test_restore_while_running_with_watchdog(
restart.assert_not_called()
@pytest.mark.usefixtures("coresys")
async def test_start_when_running(
coresys: CoreSys,
install_addon_ssh: Addon,
container: DockerContainer,
container: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test starting an addon without healthcheck."""
container.show.return_value["State"]["Status"] = "running"
container.show.return_value["State"]["Running"] = True
container.status = "running"
await install_addon_ssh.load()
await asyncio.sleep(0)
assert install_addon_ssh.state == AddonState.STARTED
@@ -722,8 +712,13 @@ async def test_start_when_running(
assert "local_ssh is already running" in caplog.text
@pytest.mark.usefixtures("test_repository", "mock_aarch64_arch_supported")
async def test_local_example_install(coresys: CoreSys, tmp_supervisor_data: Path):
async def test_local_example_install(
coresys: CoreSys,
container: MagicMock,
tmp_supervisor_data: Path,
test_repository,
mock_aarch64_arch_supported: None,
):
"""Test install of an addon."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
assert not (
@@ -737,9 +732,12 @@ async def test_local_example_install(coresys: CoreSys, tmp_supervisor_data: Path
assert data_dir.is_dir()
@pytest.mark.usefixtures("coresys", "path_extern")
async def test_local_example_start(
tmp_supervisor_data: Path, install_addon_example: Addon
coresys: CoreSys,
container: MagicMock,
tmp_supervisor_data: Path,
install_addon_example: Addon,
path_extern,
):
"""Test start of an addon."""
install_addon_example.path_data.mkdir()
@@ -756,8 +754,12 @@ async def test_local_example_start(
assert addon_config_dir.is_dir()
@pytest.mark.usefixtures("coresys", "tmp_supervisor_data")
async def test_local_example_ingress_port_set(install_addon_example: Addon):
async def test_local_example_ingress_port_set(
coresys: CoreSys,
container: MagicMock,
tmp_supervisor_data: Path,
install_addon_example: Addon,
):
"""Test start of an addon."""
install_addon_example.path_data.mkdir()
await install_addon_example.load()
@@ -765,9 +767,11 @@ async def test_local_example_ingress_port_set(install_addon_example: Addon):
assert install_addon_example.ingress_port != 0
@pytest.mark.usefixtures("tmp_supervisor_data")
async def test_addon_pulse_error(
coresys: CoreSys, install_addon_example: Addon, caplog: pytest.LogCaptureFixture
coresys: CoreSys,
install_addon_example: Addon,
caplog: pytest.LogCaptureFixture,
tmp_supervisor_data,
):
"""Test error writing pulse config for addon."""
with patch(
@@ -787,8 +791,7 @@ async def test_addon_pulse_error(
assert coresys.core.healthy is False
@pytest.mark.usefixtures("coresys")
def test_auto_update_available(install_addon_example: Addon):
def test_auto_update_available(coresys: CoreSys, install_addon_example: Addon):
"""Test auto update availability based on versions."""
assert install_addon_example.auto_update is False
assert install_addon_example.need_update is False
@@ -833,9 +836,11 @@ async def test_paths_cache(coresys: CoreSys, install_addon_ssh: Addon):
assert install_addon_ssh.with_documentation
@pytest.mark.usefixtures("mock_amd64_arch_supported")
async def test_addon_loads_wrong_image(
coresys: CoreSys, install_addon_ssh: Addon, container: DockerContainer
coresys: CoreSys,
install_addon_ssh: Addon,
container: MagicMock,
mock_amd64_arch_supported,
):
"""Test addon is loaded with incorrect image for architecture."""
coresys.addons.data.save_data.reset_mock()
@@ -847,17 +852,17 @@ async def test_addon_loads_wrong_image(
patch.object(
coresys.docker,
"run_command",
return_value=CommandReturn(0, ["Build successful"]),
new=PropertyMock(return_value=CommandReturn(0, b"Build successful")),
) as mock_run_command,
patch.object(
type(coresys.config),
"local_to_extern_path",
return_value=PurePath("/addon/path/on/host"),
return_value="/addon/path/on/host",
),
):
await install_addon_ssh.load()
container.delete.assert_called_with(force=True, v=True)
container.remove.assert_called_with(force=True, v=True)
# one for removing the addon, one for removing the addon builder
assert coresys.docker.images.delete.call_count == 2
@@ -869,7 +874,7 @@ async def test_addon_loads_wrong_image(
)
mock_run_command.assert_called_once()
assert mock_run_command.call_args.args[0] == "docker.io/library/docker"
assert mock_run_command.call_args.kwargs["tag"] == "1.0.0-cli"
assert mock_run_command.call_args.kwargs["version"] == "1.0.0-cli"
command = mock_run_command.call_args.kwargs["command"]
assert is_in_list(
["--platform", "linux/amd64"],
@@ -883,8 +888,12 @@ async def test_addon_loads_wrong_image(
coresys.addons.data.save_data.assert_called_once()
@pytest.mark.usefixtures("mock_amd64_arch_supported")
async def test_addon_loads_missing_image(coresys: CoreSys, install_addon_ssh: Addon):
async def test_addon_loads_missing_image(
coresys: CoreSys,
install_addon_ssh: Addon,
container: MagicMock,
mock_amd64_arch_supported,
):
"""Test addon corrects a missing image on load."""
coresys.docker.images.inspect.side_effect = aiodocker.DockerError(
HTTPStatus.NOT_FOUND, {"message": "missing"}
@@ -895,19 +904,19 @@ async def test_addon_loads_missing_image(coresys: CoreSys, install_addon_ssh: Ad
patch.object(
coresys.docker,
"run_command",
return_value=CommandReturn(0, ["Build successful"]),
new=PropertyMock(return_value=CommandReturn(0, b"Build successful")),
) as mock_run_command,
patch.object(
type(coresys.config),
"local_to_extern_path",
return_value=PurePath("/addon/path/on/host"),
return_value="/addon/path/on/host",
),
):
await install_addon_ssh.load()
mock_run_command.assert_called_once()
assert mock_run_command.call_args.args[0] == "docker.io/library/docker"
assert mock_run_command.call_args.kwargs["tag"] == "1.0.0-cli"
assert mock_run_command.call_args.kwargs["version"] == "1.0.0-cli"
command = mock_run_command.call_args.kwargs["command"]
assert is_in_list(
["--platform", "linux/amd64"],
@@ -920,9 +929,16 @@ async def test_addon_loads_missing_image(coresys: CoreSys, install_addon_ssh: Ad
assert install_addon_ssh.image == "local/amd64-addon-ssh"
@pytest.mark.parametrize(
"pull_image_exc",
[APIError("error"), aiodocker.DockerError(400, {"message": "error"})],
)
@pytest.mark.usefixtures("container", "mock_amd64_arch_supported")
async def test_addon_load_succeeds_with_docker_errors(
coresys: CoreSys, install_addon_ssh: Addon, caplog: pytest.LogCaptureFixture
coresys: CoreSys,
install_addon_ssh: Addon,
caplog: pytest.LogCaptureFixture,
pull_image_exc: Exception,
):
"""Docker errors while building/pulling an image during load should not raise and fail setup."""
# Build env invalid failure
@@ -931,41 +947,36 @@ async def test_addon_load_succeeds_with_docker_errors(
)
caplog.clear()
await install_addon_ssh.load()
assert "Cannot build addon 'local_ssh' because dockerfile is missing" in caplog.text
assert "Invalid build environment" in caplog.text
# Image build failure
caplog.clear()
with (
patch("pathlib.Path.is_file", return_value=True),
patch.object(
CoreConfig,
"local_to_extern_path",
return_value=PurePath("/addon/path/on/host"),
CoreConfig, "local_to_extern_path", return_value="/addon/path/on/host"
),
patch.object(
DockerAPI, "run_command", return_value=CommandReturn(1, ["error"])
DockerAPI,
"run_command",
return_value=MagicMock(exit_code=1, output=b"error"),
),
):
await install_addon_ssh.load()
assert (
"Docker build failed for local/amd64-addon-ssh:9.2.1 (exit code 1). Build output:\nerror"
"Can't build local/amd64-addon-ssh:9.2.1: Docker build failed for local/amd64-addon-ssh:9.2.1 (exit code 1). Build output:\nerror"
in caplog.text
)
# Image pull failure
install_addon_ssh.data["image"] = "test/amd64-addon-ssh"
caplog.clear()
with patch.object(
DockerAPI,
"pull_image",
side_effect=aiodocker.DockerError(400, {"message": "error"}),
):
with patch.object(DockerAPI, "pull_image", side_effect=pull_image_exc):
await install_addon_ssh.load()
assert "Can't install test/amd64-addon-ssh:9.2.1:" in caplog.text
@pytest.mark.usefixtures("coresys")
async def test_addon_manual_only_boot(install_addon_example: Addon):
async def test_addon_manual_only_boot(coresys: CoreSys, install_addon_example: Addon):
"""Test an addon with manual only boot mode."""
assert install_addon_example.boot_config == "manual_only"
assert install_addon_example.boot == "manual"
@@ -1003,35 +1014,3 @@ async def test_addon_disable_boot_dismisses_boot_fail(
install_addon_ssh.boot = AddonBoot.MANUAL
assert coresys.resolution.issues == []
assert coresys.resolution.suggestions == []
@pytest.mark.usefixtures(
"container", "mock_amd64_arch_supported", "path_extern", "tmp_supervisor_data"
)
async def test_addon_start_port_conflict_error(
coresys: CoreSys,
install_addon_ssh: Addon,
caplog: pytest.LogCaptureFixture,
):
"""Test port conflict error when trying to start addon."""
install_addon_ssh.data["image"] = "test/amd64-addon-ssh"
coresys.docker.containers.create.return_value.start.side_effect = aiodocker.DockerError(
HTTPStatus.INTERNAL_SERVER_ERROR,
"failed to set up container networking: driver failed programming external connectivity on endpoint addon_local_ssh (ea4d0fdaa72cf86f2c9199a04208e3eaf0c5a0d6fd34b3c7f4fab2daadb1f3a9): failed to bind host port for 0.0.0.0:2222:172.30.33.4:22/tcp: address already in use",
)
await install_addon_ssh.load()
caplog.clear()
with (
patch.object(Addon, "write_options"),
pytest.raises(
AddonPortConflict,
check=lambda exc: exc.extra_fields == {"name": "local_ssh", "port": 2222},
),
):
await install_addon_ssh.start()
assert (
"Cannot start container addon_local_ssh because port 2222 is already in use"
in caplog.text
)

View File

@@ -2,17 +2,15 @@
import base64
import json
from pathlib import Path, PurePath
from pathlib import Path
from unittest.mock import PropertyMock, patch
from awesomeversion import AwesomeVersion
import pytest
from supervisor.addons.addon import Addon
from supervisor.addons.build import AddonBuild
from supervisor.coresys import CoreSys
from supervisor.docker.const import DOCKER_HUB, MountType
from supervisor.exceptions import AddonBuildDockerfileMissingError
from supervisor.docker.const import DOCKER_HUB
from tests.common import is_in_list
@@ -31,7 +29,7 @@ async def test_platform_set(coresys: CoreSys, install_addon_ssh: Addon):
patch.object(
type(coresys.config),
"local_to_extern_path",
return_value=PurePath("/addon/path/on/host"),
return_value="/addon/path/on/host",
),
):
args = await coresys.run_in_executor(
@@ -55,7 +53,7 @@ async def test_dockerfile_evaluation(coresys: CoreSys, install_addon_ssh: Addon)
patch.object(
type(coresys.config),
"local_to_extern_path",
return_value=PurePath("/addon/path/on/host"),
return_value="/addon/path/on/host",
),
):
args = await coresys.run_in_executor(
@@ -83,7 +81,7 @@ async def test_dockerfile_evaluation_arch(coresys: CoreSys, install_addon_ssh: A
patch.object(
type(coresys.config),
"local_to_extern_path",
return_value=PurePath("/addon/path/on/host"),
return_value="/addon/path/on/host",
),
):
args = await coresys.run_in_executor(
@@ -108,11 +106,11 @@ async def test_build_valid(coresys: CoreSys, install_addon_ssh: Addon):
type(coresys.arch), "default", new=PropertyMock(return_value="aarch64")
),
):
assert (await build.is_valid()) is None
assert await build.is_valid()
async def test_build_invalid(coresys: CoreSys, install_addon_ssh: Addon):
"""Test build not supported because Dockerfile missing for specified architecture."""
"""Test platform set in docker args."""
build = await AddonBuild(coresys, install_addon_ssh).load_config()
with (
patch.object(
@@ -121,9 +119,8 @@ async def test_build_invalid(coresys: CoreSys, install_addon_ssh: Addon):
patch.object(
type(coresys.arch), "default", new=PropertyMock(return_value="amd64")
),
pytest.raises(AddonBuildDockerfileMissingError),
):
await build.is_valid()
assert not await build.is_valid()
async def test_docker_config_no_registries(coresys: CoreSys, install_addon_ssh: Addon):
@@ -231,7 +228,7 @@ async def test_docker_args_with_config_path(coresys: CoreSys, install_addon_ssh:
patch.object(
type(coresys.config),
"local_to_extern_path",
side_effect=lambda p: PurePath(f"/extern{p}"),
side_effect=lambda p: f"/extern{p}",
),
):
config_path = Path("/data/supervisor/tmp/config.json")
@@ -242,14 +239,13 @@ async def test_docker_args_with_config_path(coresys: CoreSys, install_addon_ssh:
config_path,
)
# Check that config is mounted (3 mounts: docker socket, addon path, config)
assert len(args["mounts"]) == 3
config_mount = next(
m for m in args["mounts"] if m.target == "/root/.docker/config.json"
# Check that config is mounted
assert "/extern/data/supervisor/tmp/config.json" in args["volumes"]
assert (
args["volumes"]["/extern/data/supervisor/tmp/config.json"]["bind"]
== "/root/.docker/config.json"
)
assert config_mount.source == "/extern/data/supervisor/tmp/config.json"
assert config_mount.read_only is True
assert config_mount.type == MountType.BIND
assert args["volumes"]["/extern/data/supervisor/tmp/config.json"]["mode"] == "ro"
async def test_docker_args_without_config_path(
@@ -268,7 +264,7 @@ async def test_docker_args_without_config_path(
patch.object(
type(coresys.config),
"local_to_extern_path",
return_value=PurePath("/addon/path/on/host"),
return_value="/addon/path/on/host",
),
):
args = await coresys.run_in_executor(
@@ -276,7 +272,7 @@ async def test_docker_args_without_config_path(
)
# Only docker socket and addon path should be mounted
assert len(args["mounts"]) == 2
assert len(args["volumes"]) == 2
# Verify no docker config mount
for mount in args["mounts"]:
assert mount.target != "/root/.docker/config.json"
for bind in args["volumes"].values():
assert bind["bind"] != "/root/.docker/config.json"

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