Updated CloudFlare
This commit is contained in:
@@ -1,9 +1,5 @@
|
||||
## What’s changed
|
||||
## ✨ New features
|
||||
|
||||
- Add Cloudflare backend connectivity check @elcajon (#432)
|
||||
|
||||
## ⬆️ Dependency updates
|
||||
|
||||
- ⬆️ Update cloudflare/cloudflared to v2023.7.0 @renovate (#434)
|
||||
- ⬆️ Update docker/setup-buildx-action action to v2.9.1 @renovate (#435)
|
||||
- ⬆️ Update Add-on base image to v14.0.3 @renovate (#439)
|
||||
- ⬆️ Update cloudflare/cloudflared to v2023.7.1 @renovate (#440)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Cloudflared
|
||||
version: 4.2.0
|
||||
version: 4.2.1
|
||||
slug: cloudflared
|
||||
description: Use a Cloudflare Tunnel to remotely connect to Home Assistant without
|
||||
opening any ports
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
[run]
|
||||
omit =
|
||||
hassio-google-drive-backup/backup/logger.py
|
||||
hassio-google-drive-backup/backup/tracing_session.py
|
||||
hassio-google-drive-backup/backup/ui/debug.py
|
||||
hassio-google-drive-backup/backup/server/cloudlogger.py
|
||||
hassio-google-drive-backup/backup/debug/*
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM python:3.9-buster
|
||||
|
||||
WORKDIR /usr/src/install
|
||||
RUN apt-get update
|
||||
RUN apt-get install fping
|
||||
# install gcloud api
|
||||
RUN echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] http://packages.cloud.google.com/apt cloud-sdk main" | tee -a /etc/apt/sources.list.d/google-cloud-sdk.list && curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key --keyring /usr/share/keyrings/cloud.google.gpg add - && apt-get update -y && apt-get install google-cloud-cli -y
|
||||
|
||||
COPY requirements-dev.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements-dev.txt
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"build": { "dockerfile": "Dockerfile" },
|
||||
"extensions": ["ms-python.python", "wholroyd.jinja","ms-python.vscode-pylance"],
|
||||
"forwardPorts": [3000]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
google-api-python-client
|
||||
google-auth-httplib2
|
||||
google-auth-oauthlib
|
||||
oauth2client
|
||||
requests
|
||||
python-dateutil==2.6.1
|
||||
watchdog
|
||||
pyyaml
|
||||
dnspython
|
||||
pytest>=5.4.0
|
||||
pytest-timeout
|
||||
pytest-repeat
|
||||
pytest-asyncio
|
||||
flake8
|
||||
mypy
|
||||
aiorun
|
||||
aiohttp
|
||||
aiodns
|
||||
aiofiles
|
||||
injector
|
||||
autopep8
|
||||
colorlog
|
||||
debugpy
|
||||
google-cloud-logging
|
||||
google-cloud-firestore
|
||||
aiohttp-jinja2
|
||||
beautifulsoup4
|
||||
firebase-admin
|
||||
aiofile
|
||||
grpcio
|
||||
aioping
|
||||
pytz
|
||||
tzlocal
|
||||
pytest-cov
|
||||
@@ -0,0 +1,6 @@
|
||||
github: sabeechen
|
||||
patreon: sabeechen
|
||||
custom:
|
||||
- https://www.buymeacoffee.com/sabeechen
|
||||
- https://www.paypal.com/paypalme/stephenbeechen
|
||||
- https://github.com/sabeechen/hassio-google-drive-backup/blob/master/donate-crypto.md
|
||||
@@ -0,0 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "06:00"
|
||||
@@ -0,0 +1,72 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master", "dev" ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ "master", "dev" ]
|
||||
schedule:
|
||||
- cron: '42 18 * * 5'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript', 'python' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
@@ -0,0 +1,13 @@
|
||||
name: Lint
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
build:
|
||||
name: Lint add-on configuration
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⤵️ Check out code from GitHub
|
||||
uses: actions/checkout@v3
|
||||
- name: 🚀 Run Home Assistant Add-on Linter
|
||||
uses: frenck/action-addon-linter@v2.13
|
||||
with:
|
||||
path: "./hassio-google-drive-backup"
|
||||
@@ -0,0 +1,31 @@
|
||||
name: push_to_prod
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy_to_staging:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.8'
|
||||
|
||||
- name: Check out dev repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
path: dev
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }}
|
||||
|
||||
- name: Publish Addon Image
|
||||
uses: home-assistant/builder@master
|
||||
with:
|
||||
args: |
|
||||
--all \
|
||||
--target dev/hassio-google-drive-backup
|
||||
@@ -0,0 +1,54 @@
|
||||
name: run-pytest
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
run-pytest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.8'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r .devcontainer/requirements-dev.txt
|
||||
- name: Install fping
|
||||
run: sudo apt-get install fping
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
# stop the build if there are Python syntax errors or undefined names
|
||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
||||
flake8 . --count --exit-zero --max-line-length=300 --statistics
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
pip install pytest
|
||||
pip install pytest-cov
|
||||
pytest hassio-google-drive-backup/tests --junitxml=junit/test-results.xml --cov=hassio-google-drive-backup/backup --cov-report=xml --cov-report=html
|
||||
- name: Upload pytest test results
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: pytest-results
|
||||
path: junit/test-results.xml
|
||||
if: ${{ always() }}
|
||||
- name: Publish Unit Test Results
|
||||
uses: EnricoMi/publish-unit-test-result-action@v2
|
||||
if: always()
|
||||
with:
|
||||
files: junit/**/*.xml
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3.1.4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./coverage.xml
|
||||
directory: ./coverage/reports/
|
||||
flags: unittests
|
||||
env_vars: OS,PYTHON
|
||||
name: codecov-umbrella
|
||||
fail_ci_if_error: true
|
||||
path_to_write_report: ./coverage/codecov_report.txt
|
||||
verbose: true
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Server Image Push
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: addon-server
|
||||
|
||||
jobs:
|
||||
# Push image to GitHub Packages.
|
||||
# See also https://docs.docker.com/docker-hub/builds/
|
||||
push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Build image
|
||||
run: docker build -f hassio-google-drive-backup/Dockerfile-server --tag $IMAGE_NAME --label "runnumber=${GITHUB_RUN_ID}" hassio-google-drive-backup/.
|
||||
|
||||
- name: Log in to registry
|
||||
# This is where you will update the PAT to GITHUB_TOKEN
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Push image
|
||||
run: |
|
||||
IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME
|
||||
|
||||
# Change all uppercase to lowercase
|
||||
IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
|
||||
# Strip git ref prefix from version
|
||||
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
|
||||
# Strip "v" prefix from tag name
|
||||
[[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
|
||||
# Use Docker `latest` tag convention
|
||||
[ "$VERSION" == "master" ] && VERSION=latest
|
||||
echo IMAGE_ID=$IMAGE_ID
|
||||
echo VERSION=$VERSION
|
||||
docker tag $IMAGE_NAME $IMAGE_ID:run-$GITHUB_RUN_NUMBER
|
||||
docker push $IMAGE_ID:run-$GITHUB_RUN_NUMBER
|
||||
@@ -0,0 +1,36 @@
|
||||
name: push_to_staging
|
||||
concurrency:
|
||||
# Only run this workflow one at a time, and cancel any in progress
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
branches:
|
||||
- dev
|
||||
inputs:
|
||||
name:
|
||||
# Friendly description to be shown in the UI instead of 'name'
|
||||
description: 'Staging Verison Override'
|
||||
# Default value if no value is explicitly provided
|
||||
default: 'increment'
|
||||
|
||||
jobs:
|
||||
run-pytest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.8'
|
||||
# Check out the current branch
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
path: dev
|
||||
# Check out the staging barnch
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
path: staging
|
||||
repository: sabeechen/hgdb-dev-staging
|
||||
- run: |
|
||||
python3 staging/update.py dev staging
|
||||
@@ -0,0 +1,55 @@
|
||||
name: push_to_staging
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
deploy_to_staging:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.8'
|
||||
|
||||
- name: Check out dev repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
path: dev
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout Staging Repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
path: staging
|
||||
repository: sabeechen/hgdb-dev-staging
|
||||
persist-credentials: true
|
||||
token: ${{ secrets.STAGING_REPO_TOKEN }}
|
||||
|
||||
- name: Update addon verison number
|
||||
run: |
|
||||
python3 staging/update.py dev staging
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_ACCESS_TOKEN }}
|
||||
|
||||
- name: Publish Staging Addon image
|
||||
uses: home-assistant/builder@master
|
||||
with:
|
||||
args: |
|
||||
--all \
|
||||
--target dev/hassio-google-drive-backup
|
||||
|
||||
- name: Publish Staging Addon Version
|
||||
run: |
|
||||
cd staging
|
||||
git config user.name github-actions
|
||||
git config user.email github-actions@github.com
|
||||
git add .
|
||||
git commit -m "Updating staging addon config"
|
||||
git push
|
||||
@@ -0,0 +1,22 @@
|
||||
*.pyc
|
||||
*.dat
|
||||
*.id
|
||||
*pycache*
|
||||
.pytest_cache/*
|
||||
htmlcov/*
|
||||
.mypy_cache/*
|
||||
*drive_creds*
|
||||
dev/backup/*.tar
|
||||
hassio-google-drive-backup/backup/__pycache__/server.cpython-37.pyc.16291344
|
||||
.vscode/tags
|
||||
dev/error.py
|
||||
dev/data/retained.json
|
||||
*.egg-info
|
||||
hassio-google-drive-backup/dev/data/id.json
|
||||
hassio-google-drive-backup/dev/data/stop_addon_state.json
|
||||
hassio-google-drive-backup/dev/data/retained.json
|
||||
junit/test-results.xml
|
||||
.coverage
|
||||
coverage.xml
|
||||
hassio-google-drive-backup/dev/data/data_cache.json
|
||||
hassio-google-drive-backup/dev/data/*.backup
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"trailingComma": "all",
|
||||
"quoteProps": "preserve",
|
||||
"singleQuote": false,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.jinja2",
|
||||
"options": {
|
||||
"printWidth": 180
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["wholroyd.jinja"]
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run Addon (Dev Backends)",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"python": "${command:python.interpreterPath}",
|
||||
"module": "hassio-google-drive-backup.backup",
|
||||
"args": ["--config", "hassio-google-drive-backup/dev/data/dev_options.json"],
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/hassio-google-drive-backup"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Run Addon (Dev Drive)",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"python": "${command:python.interpreterPath}",
|
||||
"module": "hassio-google-drive-backup.backup",
|
||||
"args": ["--config", "hassio-google-drive-backup/dev/data/drive_dev_options.json"],
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/hassio-google-drive-backup"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Run Addon (Real Google Drive)",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"python": "${command:python.interpreterPath}",
|
||||
"module": "hassio-google-drive-backup.backup",
|
||||
"args": ["--config", "hassio-google-drive-backup/dev/data/drive_options.json"],
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/hassio-google-drive-backup"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Run Mock Backend Server",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"python": "${command:python.interpreterPath}",
|
||||
"module": "hassio-google-drive-backup.dev.simulationserver",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/hassio-google-drive-backup"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Run Error Analyzer",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"python": "${command:python.interpreterPath}",
|
||||
"module": "hassio-google-drive-backup.backup.util.error_analyzer",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/hassio-google-drive-backup"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Python: Current File (External Terminal)",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"program": "${file}",
|
||||
"console": "externalTerminal",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/hassio-google-drive-backup"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Python Attach (Remote Debug docker addon)",
|
||||
"type": "python",
|
||||
"request": "attach",
|
||||
"pathMappings": [
|
||||
{
|
||||
"localRoot": "${workspaceFolder}/hassio-google-drive-backup", // You may also manually specify the directory containing your source code.
|
||||
"remoteRoot": "/app" // Linux example; adjust as necessary for your OS and situation.
|
||||
}
|
||||
],
|
||||
"port": 1627, // Set to the remote port.
|
||||
"host": "hassio", // Set to your remote host's public IP address.
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/hassio-google-drive-backup"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Run Auth Server",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"python": "${command:python.interpreterPath}",
|
||||
"module": "hassio-google-drive-backup.server",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/hassio-google-drive-backup",
|
||||
"PORT": "12345",
|
||||
"CLIENT_SECRET": "client_secret",
|
||||
"CLIENT_ID": "client_id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "(DEV) Build/Upload Addon Container",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"python": "${command:python.interpreterPath}",
|
||||
"module": "hassio-google-drive-backup.dev.deploy_dev_addon",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/hassio-google-drive-backup",
|
||||
"PORT": "12345",
|
||||
"CLIENT_SECRET": "client_secret",
|
||||
"CLIENT_ID": "client_id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "(PROD) Build/Upload Server Container",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"python": "${command:python.interpreterPath}",
|
||||
"module": "hassio-google-drive-backup.dev.deploy_server",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/hassio-google-drive-backup",
|
||||
"PORT": "12345",
|
||||
"CLIENT_SECRET": "client_secret",
|
||||
"CLIENT_ID": "client_id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "(DEV) Build/Upload Server Container",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"python": "${command:python.interpreterPath}",
|
||||
"module": "hassio-google-drive-backup.dev.deploy_dev_server",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/hassio-google-drive-backup",
|
||||
"PORT": "12345",
|
||||
"CLIENT_SECRET": "client_secret",
|
||||
"CLIENT_ID": "client_id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "(PROD) Build/Upload Addon Containers",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"python": "${command:python.interpreterPath}",
|
||||
"module": "hassio-google-drive-backup.dev.deploy_addon",
|
||||
"env": {
|
||||
"PYTHONPATH": "${workspaceFolder}/hassio-google-drive-backup",
|
||||
"PORT": "12345",
|
||||
"CLIENT_SECRET": "client_secret",
|
||||
"CLIENT_ID": "client_id"
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.nosetestsEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.linting.enabled": true,
|
||||
"python.linting.flake8Enabled": true,
|
||||
"python.linting.pylintEnabled": false,
|
||||
"python.linting.mypyEnabled": false,
|
||||
"python.linting.mypyArgs": ["--python-version", "3.8"],
|
||||
"python.linting.flake8Args": ["--ignore=E501,E731", "--verbose"],
|
||||
"python.autoComplete.addBrackets": true,
|
||||
"files.associations": {
|
||||
"*.jinja2": "html"
|
||||
},
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "vscode.html-language-features",
|
||||
"editor.tabSize": 2,
|
||||
},
|
||||
"python.analysis.completeFunctionParens": true,
|
||||
"python.analysis.typeCheckingMode": "basic"
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
{
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=733558 for help with task definitions
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "PUBLISH: New version to Docker Hub (requires working docker)",
|
||||
"type": "shell",
|
||||
"command": "./deploy.sh",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Install addon libraries",
|
||||
"type": "process",
|
||||
"command": "${config:python.pythonPath}",
|
||||
"args": ["-m", "pip", "install", "-e", "hassio-google-drive-backup/"],
|
||||
"problemMatcher": [],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Install development dependencies",
|
||||
"type": "process",
|
||||
"command": "${config:python.pythonPath}",
|
||||
"args": ["-m", "pip", "install", "-r", ".devcontainer/requirements-dev.txt"],
|
||||
"problemMatcher": [],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "[Re]create and run local addon",
|
||||
"type": "process",
|
||||
"command": "${config:python.pythonPath}",
|
||||
"args": ["-m", "hassio-google-drive-backup.dev.deploy_local_addon"],
|
||||
"problemMatcher": [],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
# Contributing
|
||||
|
||||
## About the project
|
||||
|
||||
The project is mostly maintained by Stephen Beechen (stephen@beechens.com) whom you can reach out to for guidance. Before digging in to this, you might be helpful to familiarize yourself with some of the technologies used in the project.
|
||||
|
||||
- [Developing Addons for Home Assistant](https://developers.home-assistant.io/docs/add-ons) - Useful to understand how addons work.
|
||||
- [Python](https://www.python.org/) - The addon is written in Python 3.8 and makes heavy use of the asyncio framework.
|
||||
- [AIOHTTP](https://docs.aiohttp.org/en/stable/) - The addon serves its web interface through an AIOHTTP server, and uses the AIOHTTP client library for all web requests.
|
||||
- [pytest](https://docs.pytest.org/en/latest/) - The addon uses pytest for all of its test.
|
||||
- [Visual Studio Code](https://code.visualstudio.com/) - The addon codebase is designed to work with Visual Studio code, but in practice you could use any editor (it would be harder). These instructions assume you're using VSCode, it’s a free cross-platform download.
|
||||
- [Docker](https://www.docker.com/) - All Home Assistant addons run in their own Docker container, and while you could certainly contribute without knowing much about it, knowledge of the basic commands will help.
|
||||
|
||||
## Approval Process
|
||||
- Please only make PR's against the [dev branch](https://github.com/sabeechen/hassio-google-drive-backup/tree/dev). Making a PR against master/main will result in an embarrassing song-and-dance where I ignore your PR for a little while, then ask you to remake it against dev, then ignore it again for a little while out of spite. Neither of us wants this, and you can avoid it by making it against dev in the first place.
|
||||
- If you're making a small change that fixes a bug I'm going to approve your PR quickly and heap you with praise. If you make a huge change without talking to me first I'm going to review your PR slowly and move through it with suspicion. A spectrum exists between those two extremes. Please try to understand that I'm the one ultimately on the line for the addon's reputation.
|
||||
- Breaking up a large change into smaller manageable pieces make things easier.
|
||||
- You can reach out to me in any of these ways to talk about a change you're considering:
|
||||
- Preferred: [File an issue on github](https://github.com/sabeechen/hassio-google-drive-backup/issues) proposing your changes.
|
||||
- Next best: Email: stephen@beechens.com
|
||||
- Acceptable but worst: Home Assistant Forums: [@sabeechen](https://community.home-assistant.io/u/sabeechen/summary)
|
||||
- Any submissions to the dev branch get automatically built and pushed to a staging version of the addon that you can install using [this repository](https://github.com/sabeechen/hgdb-dev-staging). Its identical to the "Production" addon but talks to [https://dev.habackup.io](https://dev.habackup.io) instead of [https://habackup.io](https://habackup.io).
|
||||
- Releases of the addon are made as-needed for bug fixes and new features. If you've made a signifigant change to the addon, you can expect me to communicate to you when you can expect to see it released. Important fixes will often demand an out-of-schedule rushed release.
|
||||
## Setting up a Development Environment
|
||||
|
||||
### Easy: Using the Dev Container
|
||||
If the you open the repository folder in Visual Studio code with docker installed, it will notice that it provides a devcontainer configuration and ask you to open it. This is the easiest method to use, sets up all the necessary plugins and dependencies, and its how I (the primary maintainer of the project) develop the addon.
|
||||
|
||||
### Harder but also works: Manual Setup
|
||||
1. Install [Visual Studio Code](https://code.visualstudio.com/)
|
||||
2. Install [Python 3.8](https://www.python.org/downloads/) for your platform.
|
||||
3. Install a git client. I like [GitHub Desktop](https://desktop.github.com/)
|
||||
4. Clone the project repository
|
||||
```
|
||||
https://github.com/sabeechen/hassio-google-drive-backup.git
|
||||
```
|
||||
5. Open Visual studio Code, go to the extension menu, and install the Desktop] (Python extension from Microsoft. It may prompt you to choose a Python interpreter (you want Python 3.8) and select a test framework (you want pytest).
|
||||
6. <kbd>File</kbd> > <kbd>Open Folder</kbd> to open the cloned repository folder.
|
||||
7. Open the terminal (`Ctrl` + `Shift` + <code>`</code>) and install the Python packages required for development:
|
||||
```
|
||||
> python3.8 -m pip install -r .devcontainer/requirements-dev.txt
|
||||
```
|
||||
That should set you up!
|
||||
|
||||
## Helpful Pointers
|
||||
|
||||
Here are some pointers about how things work that might get you to where you want to get faster:
|
||||
|
||||
- Constructor dependencies are handled through dependency injection. You can look at the attributes defined on most any class or constructor to see how they should be defined.
|
||||
- The project has almost **100% test coverage** and the expectation for all submissions (including my own) is that they will not lower that number. If you change something, your PR **must** include tests that cover it. The only exception is all the javascript, which has no unit tests.
|
||||
- The web server for the addon is in `uiserver.py`.
|
||||
- You'll want to make your changes to the `dev` branch, since the `master` branch is where new releases are made.
|
||||
|
||||
## Trying Out Changes
|
||||
|
||||
To try out changes locally during development, I've written a server that simulates Home Assistant, Supervisor, habackup.io, and Google Drive HTTP endpoints that the addon expects in [simulationserver.py](https://github.com/sabeechen/hassio-google-drive-backup/blob/master/hassio-google-drive-backup/dev/simulationserver.py). It’s a beast of a class and does a lot. It simulates the services for development and is also used to make unit tests work.
|
||||
|
||||
To give it a shot, open up Visual Studio's "Run" Dialog and start up `Run Mock Backend Server`. Then also run one of these options:
|
||||
|
||||
- `Run Addons (Dev Backends)` - This starts up the addon web server and connects it to the simulated Home Assistant, Supervisor, and Google Drive. All of the functionality of the addon is supported (creating/deleting backups, authenticating with Google drive, etc.).
|
||||
- `Run Addons (Dev Drive)` - This should be unused by contributors, as its only used for testing prior to a release by @sabeechen.
|
||||
- `Run Addons (Real Drive)` - This uses a simulated Home Assistant and Supervisor, but connects to the real Google Drive. You'll have to use a real Google account to work with this configuration.
|
||||
|
||||
## The Staging Addon
|
||||
Any submissions made to the dev branch (including PR's) get automatically built and deployed to a staging version of the addon. You can install this by adding the repository [https://github.com/sabeechen/hgdb-dev-staging](https://github.com/sabeechen/hgdb-dev-staging) to your home assistant machine. This addon is identical to what will be released with the next version of the addon but:
|
||||
- It is a separate "App" in Google's perspective, so it can't see any backups created by the "Production" addon.
|
||||
- Its not reocmmended to run it along side the "Production" addon on the same machine (it see's the same backups).
|
||||
- It talks to [https://dev.habackup.io](https://dev.habackup.io) instead of [https://habackup.io](https://habackup.io) to authenticate with Google Drive.
|
||||
- If you submit code to the dev branch, you should see an update to the addon show up in Home Assistant ~25 minutes later.
|
||||
- It is the "bleeding edge" of changes, so it might have bugs. Be warned!
|
||||
|
||||
For some changes, just testing locally might not be enough, you may want to run it as a real addon. You can do this roughly following the instruction for [Add-on Testing](https://developers.home-assistant.io/docs/add-ons/testing#local-build). Here are the two methods I've found work best:
|
||||
|
||||
- ### Building a Local Addon Container in Home Assistant
|
||||
Copy the folder `hassio-google-drive-backup` (the one with `config.json` inside it) into the local addon folder (you'll need the samba addon or something similar to do so). Modify the uploaded `config.json` to remove the `"image"` line near the bottom. Then in Home Assistant Web-UI go to <kbd>Supervisor</kbd> -> <kbd>Addon-Store</kbd>, <kbd>Reload</kbd>, and the addon should show up under "Local Addons". It should include buttons for building the container, starting/stopping etc.
|
||||
- ### Building a container
|
||||
You could also build the container as a docker container locally, upload it to Docker Hub, and then have Home Assistant download the image. First install docker desktop, then:
|
||||
```bash
|
||||
> cd hassio-google-drive-backup
|
||||
> docker login
|
||||
> docker build -f Dockerfile-addon -t YOUR_DOCKER_USERNAME/hassio-google-drive-backup-amd64:dev_testing --build-arg BUILD_FROM=homeassistant/amd64-base .
|
||||
> docker push YOUR_DOCKER_USERNAME/hassio-google-drive-backup-amd64:dev-testing
|
||||
```
|
||||
Then make a folder in the local addon directory like before, but only copy in config.json. change these two keys in config.json to match what you uploaded:
|
||||
```json
|
||||
{
|
||||
"image": "YOUR_DOCKER_USERNAME/hassio-google-drive-backup-{arch}",
|
||||
"version": "dev-testing"
|
||||
}
|
||||
```
|
||||
From there you should be able to see the addon in local addons, and installing will download the container from Docker Hub. To make it see changes, you'll need to rebuild and reupload the container, then uninstall and reinstall the addon in Home Assistant. I've found this to be faster than rebuilding the image from scratch within Home Assistant.
|
||||
> Note: Make sure you stop any other versions of the installed addon in Home Assistant before starting it as a local addon.
|
||||
|
||||
I haven't tried using the Supervisor's new devcontainers for development yet (the addon predates this), let me know if you can get that working well.
|
||||
|
||||
## Running Tests
|
||||
|
||||
You should be able to run tests from within the Visual Studio tests tab. Make sure all the tests pass before you to make a PR. You can also run them from the command line with:
|
||||
|
||||
```bash
|
||||
> python3.8 -m pytest hassio-google-drive-backup
|
||||
```
|
||||
|
||||
## Writing Tests
|
||||
|
||||
Test dependencies get injected by `pytest`, which are defined in the [conftest.py](https://github.com/sabeechen/hassio-google-drive-backup/blob/master/hassio-google-drive-backup/tests/conftest.py) file. This is responsible for starting the simulation server, mocking necessary classes, etc.
|
||||
Most classes have their own test file in the [tests](https://github.com/sabeechen/hassio-google-drive-backup/tree/master/hassio-google-drive-backup/tests) directory. If you change anything in the code, you must also submit tests with your PR that verify that change. The only exception is that all the addon's JavaScript, I've never found a good way to do JavaScript tests.
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Stephen Beechen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Using Custom/Personal Google Credentials
|
||||
You've arrived here because you'd like to use your own client ID and client secret to authenticate the add-on with Google Drive. I'll caution that this is a very detailed and complicated process geared more toward developers than end users, so if you'd like to do it the easy way, go back to your add-on (typically http://homeassistant.local:8123/hassio/ingress/hassio_google_drive_backup) and click the "Authenticate with Google Drive" button. These instructions will have you create a project on Google's Developer Cloud console, generate your own credentials, and use them to authenticate with Google Drive. You can expect this to take about 15 minutes. Typically this is what would be done by a developer when releasing a project that several users would use, but in this case you will be the only user. This workflow is for you if:
|
||||
* You'd like to avoid having your account's credentials go through a server maintained by the developer of this addon. The typical authentication workflow never sees your Google account password, but it does receive a token from Google that, if I were malicious, I could use to see the backups you've uploaded to Google Drive. I don't store this token anywhere and instead just pass it back to you, but because of how Google OAuth tokens are generated there is no way you could verify that. I tip my tinfoil hat to yours and respect your desire to protect your personal information :)
|
||||
* The typical authentication flow didn't work. This may be because of a bug, or because the server I set up to handle it is down or broken. Its just me back here providing this as a free service to the community, so apologies if things fall into disrepair.
|
||||
|
||||
These instructions are current as of June 2022. If you do this and notice they're out of date, Please file an issue on this project's issue page so I can be made aware of it and make the changes necessary. Thanks!
|
||||
|
||||
## Step 0 - Check addon version
|
||||
You must be running version 0.106.1 or greater of the add-on for this to work. In Feb 2022 Google changed how some of their authentication APIs work which broke the way the addon did it before that version
|
||||
## Step 1 - Create a Google Cloud Project
|
||||
>**NOTE:** If you already followed these steps in the past, you do not need to create a new project. Just use the one you used before.
|
||||
* Go to http://console.developers.google.com and log in with your Google account.
|
||||
* Click "Select Project" on the top left.
|
||||
* Click "New Project" to create a project.
|
||||
* Give the project any name you like, and click "Create Project". Don't worry about billing or location information, you won't be charged for anything we're doing here.
|
||||

|
||||
|
||||
## Step 2 - Enable the Drive API
|
||||
|
||||
>**NOTE:** If you already followed these steps in the past, the API shoudl already be enabled
|
||||
|
||||
With your project now created:
|
||||
* Go to https://console.developers.google.com/apis/library
|
||||
* Search for "Google Drive API", and click "Enable". This is necessary because the "Project" you're creating will use the [Google Drive API](https://developers.google.com/drive/api/v3/reference).
|
||||
|
||||
## Step 3 - Create a Consent Screen
|
||||
>**NOTE:** If you already followed these steps in the past, you probably already have a consent screen. Reuse your existing one and just make sure it matches the information described below. In particular ensure the consent screen in "Publishing status" is "In production"
|
||||
|
||||
Before creating credentials, you'll need to create a consent screen. Normally this is what people would see when they request to allow your new application to access their Google Drive, but because you're creating it just for yourself this is basically just a necessary formality.
|
||||
* Go back to http://console.developers.google.com and ensure the project name you created earlier is displayed in the upper left.
|
||||
* In the menu on the upper left, click **Enabled APIs & Services** then *OAuth Consent Screen*.
|
||||
* Select *External* for the user type and then click "Create". Even though you're probably making these credentials with the same account you'll be using to authenticate the addon, you'll still be considered an *External* user.
|
||||
* On the next screen "App Information", fill in all the required fields, *App Name*, *Support Email*, and *Developer Email*. Then click "Save & Continue". What you enter here doesn't really matter, but a good App Name is something that will make you laugh if you ever have to see this again, like "Buy the name-brand SD Card this time, maybe?"
|
||||
* On the next screen, click **Add OR Remove Scopes**. In the dialog that pops up check the box for "../auth/drive.file" and then click "Update". You might have to search for "drive.file" to make it show up. This part is very important since it gives the credentials we're about to create permission to see files in Google Drive. If you don't see this in the dialog that comes up, make sure you did step 2.
|
||||
* You can leave the rest of this form blank, just click **Save** or **Continue** for any other screens.
|
||||
* Once its created, either click **Go Back to Dashboard** or click **OAuth Consent Screen** on the left. Under **Publishing status** click **Publish App** and then **Confirm**. This dialog will warn that the app will be available to all users, but in our case it will still only be you if you keep the credentials you create later just to yourself. This step is necessary because "Testing" credentials would require you to manually re-authorize the addon ever 7 days, which is a pain.
|
||||
|
||||
## Step 4 - Create Credentials
|
||||
>**NOTE:** If you already followed these steps in the past, you probably already have credentials. If they don't whats described below, you'll need to create a new ones. In particular ensure the credential type is "TVs and Limited Input Devices". So long as you use the same consent screen, you can create any number of credentials you want.
|
||||
|
||||
Now you've set up everything necessary to actually create credentials.
|
||||
* From http://console.developers.google.com, click **Enabled APIs & Services** then **Credentials** on the left.
|
||||
* Click **+ Create Credentials** at the top of the page.
|
||||
* Select "OAuth client ID" form the drop down.
|
||||
This should have opened a dialog titled "Create OAuth client ID".
|
||||
* Select **TVs and Limited Input Devices** for **Application Type**. Home Assistant might not seem like a "Limited Input Device" but is is necessary because its the only OAuth authentication method Google provides that doesn't require you to maintain a public SSL encrypted web service.
|
||||
* Give the credentials a **Name**, anything will do and it doesn't matter.
|
||||

|
||||
* Click "Create"
|
||||
|
||||
|
||||
## Step 5 - Copy your credentials
|
||||
This should have opened a new dialog with your generated client ID and client secret. Take these back to the Add-on, and paste them into the appropriate fields of the add-on web-UI, and follow the instructions from there.
|
||||
|
||||

|
||||
@@ -0,0 +1,29 @@
|
||||
## Contributing with Cryptocurrency
|
||||
Below are the addresses and relevant QR Codes for contributiong to the project with Monreo, Ethereum and Bitcoin. Thank you for considering! If you'd like to donate using a currency not listed, make an [issue](https://github.com/sabeechen/hassio-google-drive-backup/issues) so I can consider it. Cryptocurrency might be the purest way to contribute, because I have no way of knowing who you are. Your anonymous support is appreciated <3
|
||||
<br><br>
|
||||
### <img src="images/monero-button.svg" width="200" /><br>
|
||||
<img src="images/monero-qr.svg" width="200" />
|
||||
|
||||
Monero/XMR Address:
|
||||
```
|
||||
8BtRhV9vUNkTDST7WEWuH7JumpvrFApVag5D2fVTLgfdJH8sjiy5LQo68WK4GaLBijTYb7XW6D6bChQGkDeDMmfTTTZGNTr
|
||||
```
|
||||
|
||||
<br><br>
|
||||
### <img src="images/ethereum-button.svg" width="200" /><br>
|
||||
<img src="images/ethereum-qr.svg" width="200" />
|
||||
|
||||
Ethereum/ETH Address:
|
||||
```
|
||||
0xfa455Bdd245F67Dd1D549C2Ac67EA6BBf91402a7
|
||||
```
|
||||
|
||||
|
||||
<br><br>
|
||||
### <img src="images/bitcoin-button.svg" width="200" /><br>
|
||||
<img src="images/bitcoin-qr.svg" width="200" />
|
||||
|
||||
Bitcoin/BTC Address:
|
||||
```
|
||||
bc1qystpwsqqwusam38mhwafh8n4yln798zqprsl93
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
# Authentication with Google Drive
|
||||
This document describes how the addon (Home Assistant Google Drive Backup) authenticates with Google Drive and stores your credentials. It's geared toward those who wish to know more detail and is not necessary to take advantage of the full features of the addon. The document is provided in the interest of providing full transparency into how the add-on works. I've tried to describe this as plainly as possible, but it is technical and therefore may not be understandable to everyone. Feedback on its clarity is appreciated.
|
||||
|
||||
> This document describes how authentication works if you use the big blue "AUTHENTICATE WITH GOOGLE DRIVE" button in the addon. If you're using [your own Google Drive credentials](https://github.com/sabeechen/hassio-google-drive-backup/blob/master/LOCAL_AUTH.md), then none of this applies.
|
||||
|
||||
## Your Credentials and the Needed Permission
|
||||
To have access to any information in Google Drive, Google's authentication servers must be told that the add-on has the permission. The add-on uses [Google Drive's Rest API (v3)](https://developers.google.com/drive/api/v3/about-sdk) for communication and requests the [drive.file](https://developers.google.com/drive/api/v3/about-auth) permission *scope*. This *scope* means the add-on has access to files and folders that the add-on created, but nothing else. It can't see files you've added to Google Drive through their web interface or anywhere else. Google Drive's Rest API allows the addon to periodically check what backups are uploaded and upload new ones if necessary by making requests over the internet.
|
||||
|
||||
## Authentication with Google Services
|
||||
For reference, Google's documentation for how to authenticate users with the Google Drive REST API is [here](https://developers.google.com/drive/api/v3/about-auth). Authentication is handled through [OAuth 2.0](https://developers.google.com/identity/protocols/OAuth2), which means that the add-on never actually sees your Google username and password, only an opaque [security token](https://en.wikipedia.org/wiki/Access_token) used to verify that the addon has been given permission. More detail is provided about what that token is and where it is stored later in this document.
|
||||
|
||||
The way a web-based application would normally authenticate with a Google service (eg Google Drive) looks something like this:
|
||||
1. User navigates to the app's webpage, eg http://examplegoogleapp.com
|
||||
2. The app generates a URL to Google's servers (https://accounts.google.com) used to grant the app permission.
|
||||
3. User navigates there, enters their Google username and password, and confirms the intention to give the app some permission (eg one or more *scopes*).
|
||||
4. Google redirects the user back to the app's webpage with an access token appended to the URL (eg http://examplegoogleapp.com/authenticate?token=0x12345678)
|
||||
5. The app stores the access token (0x12345678 in this example), and then passes it back to Google whenever it wishes to make access the API on behalf of the user who logged in.
|
||||
|
||||
This access token allows the app to act as if it is the user who created it. In the case of this add-on, the permission granted by the drive.file scope allows it to create folders, upload backups, and retrieve the previously created folders. Because the add-on only ever sees the access token (not the username/password), and the access token only grants limited permissions, the add-on doesn't have a way to elevate its permission further to access other information in Google Drive or your Google account.
|
||||
|
||||
## Authentication for the Add-on
|
||||
|
||||
Google puts some limitations on how the access token must be generated that will be important for understanding how the add-on authenticates in reality:
|
||||
* When the user is redirected to https://accounts.google.com (step 2), the redirect must be from a known public website associated with the app.
|
||||
* When the user is redirected back to the app after authorization (step 4), the redirect must be a statically addressed and publicly accessible website.
|
||||
|
||||
These limitations make a technical problem for the addon because most people's Home Assistant instances aren't publicly accessible and the address is different for each one. Performing the authentication workflow exactly as described above won't work. To get around this, I (the developer of this addon) set up a website, https://habackup.io, which serves as the known public and statically addressable website that Google redirects from/to. The source code for this server is available within the add-on's GitHub repository.
|
||||
|
||||
So when you authenticate the add-on, the workflow looks like this:
|
||||
1. You start at the add-on's web interface, something like https://homeassistant.local:8123/ingress/hassio_google_drive_backup
|
||||
2. You click the "Authenticate With Google Drive" button, which takes note of the address of your Home Assistant installation (https://homeassistant.local:8123 in this case) and sends you to https://habackup.io/drive/authorize
|
||||
3. https://habackup.io immediately generates the Google login URL for you and redirects you to https://accounts.google.com
|
||||
4. You log in with your Google credentials on Google's domain, and confirm you want to give the add-on permission to see files and folders it creates (the drive.file scope)
|
||||
5. Google redirects you back to https://habackup.io, along with the access token that will be used for future authentication.
|
||||
6. https://habackup.io redirects you back to your add-on web-UI (which is kept track of in step 2) along with the access token.
|
||||
7. The addon (on your local Home Assistant installation) persists the access token and uses it in the future any time it needs to talk to Google Drive.
|
||||
|
||||
Notably, your access token isn't persisted at https://habackup.io, it is only passed through back to your local add-on installation. I do this because:
|
||||
- It ensures your information is only ever stored on your machine, which is reassuring from the user's perspective (eg you).
|
||||
- If my server (https://habackup.io) ever gets compromised, there isn't any valuable information stored there that compromises you as well.
|
||||
- This is practicing a form of [defense-in-depth](https://en.wikipedia.org/wiki/Defense_in_depth_%28computing%29) security, where-in [personal data](https://en.wikipedia.org/wiki/Personal_data) is only stored in the places where it is strictly critical.
|
||||
- It makes the server more simple since it is a stateless machine that doesn't require a database (eg to store your token).
|
||||
|
||||
After your token is generated and stored on your machine, it needs to be *refreshed* periodically with Google Drive. To do this, the addon will again ask https://habackup.io who will relay the request with Google Drive.
|
||||
@@ -0,0 +1,123 @@
|
||||
# 'Snapshot' vs 'Backup'
|
||||
In August 2021 [the Home Assistant team announced](https://www.home-assistant.io/blog/2021/08/24/supervisor-update/) that 'snapshots' will be called 'backups' moving forward. This addon exposes a binary sensor to indicate if snapshots are stale and a another sensor that publishes details about backups. Both of the sensors used 'snapshot' in their names and values, so they had to be changed to match the new language. To prevent breaking any existing automations you might have, the addon will only start using the new names and values when you upgrade if you tell it to.
|
||||
|
||||
This can be controlled by using the configuration option ```call_backup_snapshot```, which will use the old names and values for sensors when it is true. If you updated the addon from a version that used to use 'snapshot' in it names, this option will be automatically added when you update to make sure it doesn't break any existing automations.
|
||||
|
||||
Here is a breakdown of what the new and old sensor values mean:
|
||||
|
||||
## Old sensor name/values
|
||||
These will be the sensor values used when ```call_backup_snapshot: True``` or if the addon is below version 0.105.1. The addon sets ```call_backup_snapshot: True``` automatically if you upgrade the addon from an older version.
|
||||
### Backup Stale Binary Sensor
|
||||
#### Entity Id:
|
||||
```yaml
|
||||
binary_sensor.snapshots_stale
|
||||
```
|
||||
#### Possible states:
|
||||
```yaml
|
||||
on
|
||||
off
|
||||
```
|
||||
#### Example Attributes:
|
||||
```yaml
|
||||
friendly_name: Snapshots Stale
|
||||
device_class: problem
|
||||
```
|
||||
### Backup State Sensor
|
||||
#### Entity Id:
|
||||
```yaml
|
||||
sensor.snapshot_backup
|
||||
```
|
||||
#### Possible States:
|
||||
```yaml
|
||||
error
|
||||
waiting
|
||||
backed_up
|
||||
```
|
||||
#### Example Attributes:
|
||||
```yaml
|
||||
friendly_name: Snapshots State
|
||||
last_snapshot: 2021-09-01T20:26:49.100376+00:00
|
||||
snapshots_in_google_drive: 2
|
||||
snapshots_in_hassio: 2
|
||||
snapshots_in_home_assistant: 2
|
||||
size_in_google_drive: 2.5 GB
|
||||
size_in_home_assistant: 2.5 GB
|
||||
snapshots:
|
||||
- name: Full Snapshot 2021-02-06 11:37:00
|
||||
date: '2021-02-06T18:37:00.916510+00:00'
|
||||
state: Backed Up
|
||||
slug: DFG123
|
||||
- name: Full Snapshot 2021-02-07 11:00:00
|
||||
date: '2021-02-07T18:00:00.916510+00:00'
|
||||
state: Backed Up
|
||||
slug: DFG124
|
||||
```
|
||||
|
||||
## New Sensor Names/Values
|
||||
These will be the sensor values used when ```call_backup_snapshot: False``` or if the configuration option is un-set. New installations of the addon will default to this.
|
||||
### Backup Stale Binary Sensor
|
||||
#### Entity Id
|
||||
```yaml
|
||||
binary_sensor.backups_stale
|
||||
```
|
||||
#### Possible States
|
||||
```yaml
|
||||
on
|
||||
off
|
||||
```
|
||||
#### Example Attributes:
|
||||
```yaml
|
||||
friendly_name: Backups Stale
|
||||
device_class: problem
|
||||
```
|
||||
### Backup State Sensor
|
||||
#### Entity Id
|
||||
```yaml
|
||||
sensor.backup_state
|
||||
```
|
||||
#### Possible States
|
||||
```yaml
|
||||
error
|
||||
waiting
|
||||
backed_up
|
||||
```
|
||||
#### Example Attributes:
|
||||
```yaml
|
||||
friendly_name: Backup State
|
||||
last_backup: 2021-09-01T20:26:49.100376+00:00
|
||||
last_upload: 2021-09-01T20:26:49.100376+00:00
|
||||
backups_in_google_drive: 2
|
||||
backups_in_home_assistant: 2
|
||||
size_in_google_drive: 2.5 GB
|
||||
size_in_home_assistant: 2.5 GB
|
||||
backups:
|
||||
- name: Full Snapshot 2021-02-06 11:37:00
|
||||
date: '2021-02-06T18:37:00.916510+00:00
|
||||
state: Backed Up
|
||||
slug: DFG123
|
||||
- name: Full Snapshot 2021-02-07 11:00:00
|
||||
date: '2021-02-07T18:00:00.916510+00:00'
|
||||
state: Backed Up
|
||||
slug: DFG124
|
||||
```
|
||||
|
||||
### What do the values mean?
|
||||
```binary_sensor.backups_stale``` is "on" when backups are stale and "off"" otherwise. Backups are stale when the addon is 6 hours past a scheduled backup and no new backup has been made. This delay is in place to avoid triggerring on transient errors (eg internet connectivity problems or one-off problems in Home Assistant).
|
||||
|
||||
```sensor.backup_state``` is:
|
||||
- ```waiting``` when the addon is first booted up or hasn't been connected to Google Drive yet.
|
||||
- ```error``` immediately after any error is encountered, even transient ones.
|
||||
- ```backed_up``` when everything is running fine without errors.
|
||||
|
||||
It's attributes are:
|
||||
- ```last_backup``` The UTC ISO-8601 date of the most recent backup in Home Assistant or Google Drive.
|
||||
- ```last_upload``` The UTC ISO-8601 date of the most recent backup uploaded to Google Drive.
|
||||
- ```backups_in_google_drive``` The number of backups in Google Drive.
|
||||
- ```backups_in_home_assistant``` The number of backups in Home Assistant.
|
||||
- ```size_in_google_drive``` A string representation of the space used by backups in Google Drive.
|
||||
- ```size_in_home_assistant``` A string representation of the space used by backups in Home Assistant.
|
||||
- ```backups``` The list of each snapshot in decending order of date. Each snapshot includes its ```name```, ```date```, ```slug```, and ```state```. ```state``` can be one of:
|
||||
- ```Backed Up``` if its in Home Assistant and Google Drive.
|
||||
- ```HA Only``` if its only in Home Assistant.
|
||||
- ```Drive Only``` if its only in Google Drive.
|
||||
- ```Pending``` if the snapshot was requested but not yet complete.
|
||||
@@ -0,0 +1,26 @@
|
||||
## v0.111.1 [2023-06-19]
|
||||
- Support for the new network storage features in Home Assistant. The addon will now create backups in what Home Assistant has configured as its default backup location. This can be overridden in the addon's settings.
|
||||
- Raised the addon's required permissions to "Admin" in order to access the supervisor's mount API.
|
||||
- Fixed a CSS error causing toast messages to render partially off screen on small displays.
|
||||
- Fixed misreporting of some error codes from Google Drive when a partial upload can't be resumed.
|
||||
|
||||
## v0.110.4 [2023-04-28]
|
||||
- Fix a whitespace error causing authorization to fail.
|
||||
|
||||
## v0.110.3 [2023-03-24]
|
||||
- Fix an error causing "Days Between Backups" to be ignored when "Time of Day" for a backup is set.
|
||||
- Fix a bug causing some timezones to make the addon to fail to start.
|
||||
|
||||
## v0.110.2 [2023-03-24]
|
||||
- Fix a potential cause of SSL errors when communicating with Google Drive
|
||||
- Fix a bug causing backups to be requested indefinitely if scheduled during DST transitions.
|
||||
|
||||
## v0.110.1 [2023-01-09]
|
||||
- Adds some additional options for donating
|
||||
- Mitgigates SD card corruption by redundantly storing config files needed for addon startup.
|
||||
- Avoid global throttling of Google Drive API calls by:
|
||||
- Making sync intervals more spread out and a little random.
|
||||
- Syncing more selectively when there are modifications to the /backup directory.
|
||||
- Caching data from Google Drive for short periods during periodic syncing.
|
||||
- Backing off for a longer time (2 hours) when the addon hits permanent errors.
|
||||
- Fixes CSS issues that made the logs page hard to use.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Home Assistant Add-on: Google Assistant SDK
|
||||
|
||||
## Installation
|
||||
|
||||
To install the add-on, first follow the installation steps from the [README on GitHub](https://github.com/sabeechen/hassio-google-drive-backup#installation).
|
||||
|
||||
## Configuration
|
||||
|
||||
_Note_: The configuration can be changed easily by starting the add-on and clicking `Settings` in the web UI.
|
||||
The UI explains what each setting is and you don't need to modify anything before clicking `Start`.
|
||||
If you would still prefer to modify the settings in yaml, the options are detailed below.
|
||||
|
||||
### Add-on configuration example
|
||||
Don't use this directly, the addon has a lot of configuration options that most users don't need or want:
|
||||
|
||||
```yaml
|
||||
# Keep 10 backups in Home Assistant
|
||||
max_backups_in_ha: 10
|
||||
|
||||
# Keep 10 backups in Google Drive
|
||||
max_backups_in_google_drive: 10
|
||||
|
||||
# Create backups in Home Assistant on network storage
|
||||
backup_location: my_nfs_share
|
||||
|
||||
# Ignore backups the add-on hasn't created
|
||||
ignore_other_backups: True
|
||||
|
||||
# Ignore backups that look like they were created by Home Assistant automatic backup option during upgrades
|
||||
ignore_upgrade_backups: True
|
||||
|
||||
# Automatically delete "ignored" snapshots after this many days
|
||||
delete_ignored_after_days: 7
|
||||
|
||||
# Take a backup every 3 days
|
||||
days_between_backups: 3
|
||||
|
||||
# Create backups at 1:30pm exactly
|
||||
backup_time_of_day: "13:30"
|
||||
|
||||
# Delete backups from Home Assistant immediately after uploading them to Google Drive
|
||||
delete_after_upload: True
|
||||
|
||||
# Manually specify the backup folder used in Google Drive
|
||||
specify_backup_folder: true
|
||||
|
||||
# Use a dark and red theme
|
||||
background_color: "#242424"
|
||||
accent_color: "#7D0034"
|
||||
|
||||
# Use a password for backup archives. Use "!secret secret_name" to use a password form your secrets file
|
||||
backup_password: "super_secret"
|
||||
|
||||
# Create backup names like 'Full Backup HA 0.92.0'
|
||||
backup_name: "{type} Backup HA {version_ha}"
|
||||
|
||||
# Keep a backup once every day for 3 days and once a week for 4 weeks
|
||||
generational_days: 3
|
||||
generational_weeks: 4
|
||||
|
||||
# Create partial backups with no folders and no configurator add-on
|
||||
exclude_folders: "homeassistant,ssl,share,addons/local,media"
|
||||
exclude_addons: "core_configurator"
|
||||
|
||||
# Turn off notifications and staleness sensor
|
||||
enable_backup_stale_sensor: false
|
||||
notify_for_stale_backups: false
|
||||
|
||||
# Enable server directly on port 1627
|
||||
expose_extra_server: true
|
||||
|
||||
# Allow sending error reports
|
||||
send_error_reports: true
|
||||
|
||||
# Delete backups after they're uploaded to Google Drive
|
||||
delete_after_upload: true
|
||||
```
|
||||
|
||||
### Option: `max_backups_in_ha` (default: 4)
|
||||
|
||||
The number of backups the add-on will allow Home Assistant to store locally before old ones are deleted.
|
||||
|
||||
### Option: `max_backups_in_google_drive` (default: 4)
|
||||
|
||||
The number of backups the add-on will keep in Google Drive before old ones are deleted. Google Drive gives you 15GB of free storage (at the time of writing) so plan accordingly if you know how big your backups are.
|
||||
|
||||
### Option: `backup_location` (default: None)
|
||||
The place where backups are created in Home Assistant before uploading to Google Drive. Can be "local-disk" or the name of any backup network storage you've configured in Home Assistant. Leave unspecified (the default) to have backups created in whatever Home Assistant uses as the default backup location.
|
||||
|
||||
### Option: `ignore_other_backups` (default: False)
|
||||
Make the addon ignore any backups it didn't directly create. Any backup already uploaded to Google Drive will not be ignored until you delete it from Google Drive.
|
||||
|
||||
### Option: `ignore_upgrade_backups` (default: False)
|
||||
Ignores backups that look like they were automatically created from updating an add-on or Home Assistant itself. This will make the add-on ignore any partial backup that has only one add-on or folder in it.
|
||||
|
||||
### Option: `days_between_backups` (default: 3)
|
||||
|
||||
How often a new backup should be scheduled, eg `1` for daily and `7` for weekly.
|
||||
|
||||
### Option: `backup_time_of_day`
|
||||
|
||||
The time of day (local time) that new backups should be created in 24-hour ("HH:MM") format. When not specified backups are created at (roughly) the same time of day as the most recent backup.
|
||||
|
||||
|
||||
### Options: `delete_after_upload` (default: False)
|
||||
|
||||
Deletes backups from Home Assistant immediately after uploading them to Google Drive. This is useful if you have very limited space inside Home Assistant since you only need to have available space for a single backup locally.
|
||||
|
||||
### Option: `specify_backup_folder` (default: False)
|
||||
|
||||
When true, you must select the folder in Google Drive where backups are stored. Once you turn this on, restart the add-on and visit the Web-UI to be prompted to select the backup folder.
|
||||
|
||||
### Option: `background_color` and `accent_color`
|
||||
|
||||
The background and accent colors for the web UI. You can use this to make the UI fit in with whatever color scheme you use in Home Assistant. When unset, the interface matches Home Assistant's default blue/white style.
|
||||
|
||||
### Option: `backup_password`
|
||||
|
||||
When set, backups are created with a password. You can use a value from your secrets.yaml by prefixing the password with "!secret". You'll need to remember this password when restoring a backup.
|
||||
|
||||
> Example: Use a password for backup archives
|
||||
>
|
||||
> ```yaml
|
||||
> backup_password: "super_secret"
|
||||
> ```
|
||||
>
|
||||
> Example: Use a password from secrets.yaml
|
||||
>
|
||||
> ```yaml
|
||||
> backup_password: "!secret backup_password"
|
||||
> ```
|
||||
|
||||
### Option: `backup_name` (default: "{type} Backup {year}-{month}-{day} {hr24}:{min}:{sec}")
|
||||
|
||||
Sets the name for new backups. Variable parameters of the form `{variable_name}` can be used to modify the name to your liking. A list of available variables is available [here](https://github.com/sabeechen/hassio-google-drive-backup#can-i-give-backups-a-different-name).
|
||||
|
||||
### Option: `generational_*`
|
||||
|
||||
When set, older backups will be kept longer using a [generational backup scheme](https://en.wikipedia.org/wiki/Backup_rotation_scheme). See the [question here](https://github.com/sabeechen/hassio-google-drive-backup#can-i-keep-older-backups-for-longer) for configuration options.
|
||||
|
||||
### Option: `exclude_folders`
|
||||
|
||||
When set, excludes the comma-separated list of folders by creating a partial backup.
|
||||
|
||||
### Option: `exclude_addons`
|
||||
|
||||
When set, excludes the comma-separated list of addons by creating a partial backup.
|
||||
|
||||
_Note_: Folders and add-ons must be identified by their "slug" name. It is recommended to use the `Settings` dialog within the add-on web UI to configure partial backups since these names are esoteric and hard to find.
|
||||
|
||||
### Option: `enable_backup_stale_sensor` (default: True)
|
||||
|
||||
When false, the add-on will not publish the [binary_sensor.backups_stale](https://github.com/sabeechen/hassio-google-drive-backup#how-will-i-know-this-will-be-there-when-i-need-it) stale sensor.
|
||||
|
||||
### Option: `enable_backup_state_sensor` (default: True)
|
||||
|
||||
When false, the add-on will not publish the [sensor.backup_state](https://github.com/sabeechen/hassio-google-drive-backup#how-will-i-know-this-will-be-there-when-i-need-it) sensor.
|
||||
|
||||
### Option: `notify_for_stale_backups` (default: True)
|
||||
|
||||
When false, the add-on will send a [persistent notification](https://github.com/sabeechen/hassio-google-drive-backup#how-will-i-know-this-will-be-there-when-i-need-it) in Home Assistant when backups are stale.
|
||||
|
||||
---
|
||||
|
||||
### UI Server Options
|
||||
|
||||
The UI is available through Home Assistant [ingress](https://www.home-assistant.io/blog/2019/04/15/hassio-ingress/).
|
||||
|
||||
It can also be exposed through a web server on port `1627`, which you can map to an externally visible port from the add-on `Network` panel. You can configure a few more options to add SSL or require your Home Assistant username/password.
|
||||
|
||||
#### Option: `expose_extra_server` (default: False)
|
||||
|
||||
Expose the webserver on port `1627`. This is optional, as the add-on is already available with Home Assistant ingress.
|
||||
|
||||
#### Option: `require_login` (default: False)
|
||||
|
||||
When true, requires your home assistant username and password to access the Web UI.
|
||||
|
||||
#### Option: `use_ssl` (default: False)
|
||||
|
||||
When true, requires your home assistant username and password to access the Web UI.
|
||||
|
||||
#### Option: `certfile` (default: `/ssl/certfile.pem`)
|
||||
|
||||
Required when `use_ssl: True`. The path to your SSL key file
|
||||
|
||||
#### Option: `keyfile` (default: `/ssl/keyfile.pem`)
|
||||
|
||||
Required when `use_ssl: True`. The path to your SSL cert file.
|
||||
|
||||
#### Option: `verbose` (default: False)
|
||||
|
||||
If true, enable additional debug logging. Useful if you start seeing errors and need to file a bug with me.
|
||||
|
||||
#### Option: `send_error_reports` (default: False)
|
||||
|
||||
When true, the text of unexpected errors will be sent to a database maintained by the developer. This helps identify problems with new releases and provide better context messages when errors come up.
|
||||
|
||||
#### Option: `delete_after_upload` (default: False)
|
||||
|
||||
When true, backups are always deleted after they've been uploaded to Google Drive. 'max_backups_in_ha' is ignored when this option is True, since a backup is always deleted from Home Assistant after it gets uploaded to Google Drive. Some find this useful if they only have enough space on their Home Assistant machine for one backup.
|
||||
|
||||
## FAQ
|
||||
|
||||
Read the [FAQ on GitHub](https://github.com/sabeechen/hassio-google-drive-backup#faq).
|
||||
@@ -0,0 +1,12 @@
|
||||
ARG BUILD_FROM
|
||||
FROM $BUILD_FROM
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
RUN chmod +x addon_deps.sh
|
||||
RUN ./addon_deps.sh
|
||||
RUN pip3 install .
|
||||
COPY config.json /usr/local/lib/python3.8/site-packages/config.json
|
||||
|
||||
EXPOSE 1627
|
||||
EXPOSE 8099
|
||||
ENTRYPOINT ["python3", "-m", "backup"]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Use the official lightweight Python image.
|
||||
# https://hub.docker.com/_/python
|
||||
FROM python:3.9-buster
|
||||
|
||||
# Copy local code to the container image.
|
||||
ENV APP_HOME /server
|
||||
WORKDIR $APP_HOME
|
||||
COPY . ./
|
||||
COPY config.json /usr/local/lib/python3.9/site-packages/config.json
|
||||
|
||||
# Install server python requirements
|
||||
RUN pip3 install --trusted-host pypi.python.org -r requirements-server.txt
|
||||
RUN pip3 install .
|
||||
|
||||
WORKDIR /
|
||||
ENTRYPOINT ["python3", "-m", "backup.server"]
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generational Backup
|
||||
Generational backup lets you keep a longer history of backups on daily, weekly, monthly, and yearly cycles. This is in contrast to the "regular" scheme for keeping history backups, which will always just delete the oldest backup when needed. This has the effect of keeping older backups around for a longer time, which is particularly useful if you've made a bad configuration change but didn't notice until several days later.
|
||||
|
||||
## Configuration
|
||||
The generational backup will be used when any one of `generational_days`, `generational_weeks`, `generational_months`, or `generational_years` is greater than zero. All of the available configuration options are given below, but utes much easier to configure from the Settings dialog accessible from the "Settings" menu at the top of the web UI.
|
||||
* `generational_days` (int): The number of days to keep
|
||||
* `generational_weeks` (int): The number of weeks to keep
|
||||
* `generational_months` (int): The number of months to keep
|
||||
* `generational_years` (int): The number of years to keep
|
||||
* `generational_day_of_week` (str): The day of the week when weekly backups will be kept. It can be one of 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' or 'sun'. The default is 'mon'.
|
||||
* `generational_day_of_month` (int): The day of the month when monthly backups will be kept, from 1 to 31. If a month has less than the configured number of days, the latest day of that month is used.
|
||||
* `generational_day_of_year` (int): The day of the year that yearly backups are kept, from 1 to 365.
|
||||
|
||||
## Some Details to Consider
|
||||
* Generational backup assumes that a backup is available for every day to work properly, so it's recommended that you set `days_between_backups`=1 if you're using the feature. Otherwise, a backup may not be available to be saved for a given day.
|
||||
* The backups maintained by generational backup will still never exceed the number you permit to be maintained in Google Drive or Home Assistant. For example, if `max_backups_in_google_drive`=3 and `generational_weeks`=4, then only 3 weeks of backups will be kept in Google Drive.
|
||||
* Generational backup will only delete older backups when it has to. For example, if you've configured it to keep 5 weekly backups on Monday, you've been running it for a week (so you have 7 backups), and `max_backups_in_google_drive`=7, then your backups on Tuesday, Wednesday, etc won't get deleted yet. They won't get deleted until doing so is necessary to keep older backups around without violating the maximum allowed in Google Drive.
|
||||
>Note: You can configure the addon to delete backups more aggressively by setting `generational_delete_early`=true. With this, the addon will delete old backups that don't match a daily, weekly, monthly, or yearly configured cycle even if you aren't yet at risk of exceeding `max_backups_in_ha` or `max_backups_in_google_drive`. Careful though! You can accidentally delete all your backups this way if you don't have all your settings configured just the way you want them.
|
||||
* If more than one backup is created for a day (for example if you create one manually) then only the latest backup from that day will be kept.
|
||||
|
||||
## Schedule
|
||||
Figuring out date math in your head is hard, so it's useful to see a concrete example. Consider you have the following configuration. Two backups for each day, week, month, and year along with a limit in Google drive large enough to accommodate them all:
|
||||
```json
|
||||
"days_between_backups": 1,
|
||||
"generational_days": 2,
|
||||
"generational_weeks": 2,
|
||||
"generational_months": 2
|
||||
"generational_years": 2
|
||||
"max_backups_in_google_drive": 8
|
||||
```
|
||||
Imagine you've been running the add-on for 2 years now, diligently making a backup every day with no interruptions. On 19 May 2021, you could expect your list of backups in Google Drive to look like this:
|
||||
- May 19, 2021 <-- 1st Daily backup
|
||||
- May 18, 2021 <-- 2nd Daily backup
|
||||
- May 13, 2021 <-- 1st Weekly backup
|
||||
- May 06, 2021 <-- 2nd Weekly backup
|
||||
- May 01, 2021 <-- 1st Monthly backup
|
||||
- April 01, 2021 <-- 2nd Monthly backup
|
||||
- January 01, 2021 <-- 1st Yearly backup
|
||||
- January 01, 2020 <-- 2nd Yearly backup
|
||||
|
||||
Note that sometimes a day might overlap more than one schedule. For example, a backup on January 1st could satisfy the constraints for both a yearly and monthly backup. In this case, the add-on will only delete older backups when it *must* to keep from exceeding `max_backups_in_ha` or `max_backups_in_google_drive`. Thus, the most recent backup that would otherwise be deleted will be kept until space is needed somewhere else in the schedule.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Home Assistant Add-on: Google Drive Backup
|
||||
|
||||
A complete and easy way to upload your Home Assistant backups to Google Drive.
|
||||
|
||||
## About
|
||||
|
||||
Quickly set up a backup strategy without much fuss. It doesn't require much familiarity with Home Assistant, its architecture, or Google Drive. Detailed install instructions are provided below but you can just add the repo, click install and open the Web UI. It will tell you what to do and only takes a few simple clicks.
|
||||
|
||||
>This project requires financial support to make the Google Drive integration work, but it is free for you to use. You can join those helping to keep the lights on at:
|
||||
>
|
||||
>[<img src="https://raw.githubusercontent.com/sabeechen/hassio-google-drive-backup/master/images/bmc-button.svg" width=150 height=40 style="margin: 5px"/>](https://www.buymeacoffee.com/sabeechen)
|
||||
>[<img src="https://raw.githubusercontent.com/sabeechen/hassio-google-drive-backup/master/images/paypal-button.svg" width=150 height=40 style="margin: 5px"/>](https://www.paypal.com/paypalme/stephenbeechen)
|
||||
>[<img src="https://raw.githubusercontent.com/sabeechen/hassio-google-drive-backup/master/images/patreon-button.svg" width=150 height=40 style="margin: 5px"/>](https://www.patreon.com/bePatron?u=4064183)
|
||||
>[<img src="https://raw.githubusercontent.com/sabeechen/hassio-google-drive-backup/master/images/github-sponsors-button.svg" width=150 height=40 style="margin: 5px"/>](https://github.com/sponsors/sabeechen)
|
||||
>[<img src="https://raw.githubusercontent.com/sabeechen/hassio-google-drive-backup/master/images/monero-button.svg" width=150 height=40 style="margin: 5px"/>](https://github.com/sabeechen/hassio-google-drive-backup/blob/master/donate-crypto.md)
|
||||
>[<img src="https://raw.githubusercontent.com/sabeechen/hassio-google-drive-backup/master/images/bitcoin-button.svg" width=150 height=40 style="margin: 5px"/>](https://github.com/sabeechen/hassio-google-drive-backup/blob/master/donate-crypto.md)
|
||||
>[<img src="https://raw.githubusercontent.com/sabeechen/hassio-google-drive-backup/master/images/ethereum-button.svg" width=150 height=40 style="margin: 5px"/>](https://github.com/sabeechen/hassio-google-drive-backup/blob/master/donate-crypto.md)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- Creates backups on a configurable schedule.
|
||||
- Uploads backups to Drive, even the ones it didn't create.
|
||||
- Clean up old backups in Home Assistant and Google Drive, so you don't run out of space.
|
||||
- Restore from a fresh install or recover quickly from disaster by uploading your backups directly from Google Drive.
|
||||
- Integrates with Home Assistant Notifications, and provides sensors you can trigger off of.
|
||||
- Notifies you when something goes wrong with your backups.
|
||||
- Super easy installation and configuration.
|
||||
- Privacy-centric design philosophy.
|
||||
- Comprehensive documentation.
|
||||
- _Most certainly_ doesn't mine bitcoin on your home automation server. Definitely no.
|
||||
|
||||
See the [README on GitHub](https://github.com/sabeechen/hassio-google-drive-backup) for all the details, or just install the add-on and open the Web UI.
|
||||
The Web-UI explains everything you have to do.
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
apk add python3 fping linux-headers libc-dev libffi-dev python3-dev gcc py3-pip
|
||||
pip3 install --upgrade pip wheel setuptools
|
||||
pip3 install --trusted-host pypi.python.org -r requirements-addon.txt
|
||||
# Remove packages we only needed for installation
|
||||
apk del linux-headers libc-dev libffi-dev python3-dev gcc
|
||||
@@ -0,0 +1,22 @@
|
||||
import platform
|
||||
import asyncio
|
||||
from aiorun import run
|
||||
from injector import Injector
|
||||
|
||||
from backup.module import MainModule, BaseModule
|
||||
from backup.starter import Starter
|
||||
|
||||
|
||||
async def main():
|
||||
await Injector([BaseModule(), MainModule()]).get(Starter).start()
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if platform.system() == "Windows":
|
||||
# Needed for dev on windows machines
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
asyncio.run(main())
|
||||
else:
|
||||
run(main())
|
||||
@@ -0,0 +1,11 @@
|
||||
# flake8: noqa
|
||||
from .config import Config, GenConfig, UPGRADE_OPTIONS
|
||||
from .settings import Setting, _DEFAULTS, _VALIDATORS, _LOOKUP, VERSION, PRIVATE, isStaging, addon_config, _CONFIG
|
||||
from .createoptions import CreateOptions
|
||||
from .boolvalidator import BoolValidator
|
||||
from .startable import Startable
|
||||
from .listvalidator import ListValidator
|
||||
from .durationasstringvalidator import DurationAsStringValidator
|
||||
from .bytesizeasstringvalidator import BytesizeAsStringValidator
|
||||
from .version import Version
|
||||
from .durationparser import DurationParser
|
||||
@@ -0,0 +1,18 @@
|
||||
from .validator import Validator
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class BoolValidator(Validator):
|
||||
def __init__(self, name):
|
||||
super().__init__(name)
|
||||
|
||||
def validate(self, value):
|
||||
if value is None or (type(value) == str and len(value) == 0):
|
||||
return None
|
||||
return BoolValidator.strToBool(value)
|
||||
|
||||
@classmethod
|
||||
def strToBool(cls, value) -> bool:
|
||||
return str(value).lower() in ['true', 't', 'on', 'yes', 'y', '1', 'hai', 'si', 'omgyesplease']
|
||||
@@ -0,0 +1,56 @@
|
||||
import re
|
||||
from injector import inject, singleton
|
||||
|
||||
SECOND_IDENTIFIERS = ["s", "sec", "secs", "second", "seconds"]
|
||||
MINUTE_IDENTIFIERS = ["m", "min", "mins", "minute", "minutes"]
|
||||
HOUR_IDENTIFIERS = ["h", "hr", "hour", "hours"]
|
||||
DAY_IDENTIFIERS = ["d", "day", "days"]
|
||||
NUMBER_REGEX = "^([0-9]*[.])?[0-9]+"
|
||||
VALID_REGEX = "^[ ]*([0-9,]*\\.?[0-9]*)[ ]*(b|B|k|K|m|M|g|G|t|T|p|P|e|E|z|Z|y|Y)[a-zA-Z ]*[ ]*$"
|
||||
BYTES_BASE = 1024
|
||||
PREFIX_VALUES = {
|
||||
"b": 1,
|
||||
"k": BYTES_BASE,
|
||||
"m": pow(BYTES_BASE, 2),
|
||||
"g": pow(BYTES_BASE, 3),
|
||||
"t": pow(BYTES_BASE, 4),
|
||||
"p": pow(BYTES_BASE, 5),
|
||||
"e": pow(BYTES_BASE, 6),
|
||||
"z": pow(BYTES_BASE, 7),
|
||||
"y": pow(BYTES_BASE, 8)
|
||||
}
|
||||
|
||||
PREFIX_CANONICAL = ["", "K", "M", "G", "T", "P", "E", "Z", "Y"]
|
||||
|
||||
|
||||
@singleton
|
||||
class ByteFormatter():
|
||||
@inject
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def parse(self, source: str):
|
||||
source = source.lower()
|
||||
match = re.match(VALID_REGEX, source.lower())
|
||||
if not match:
|
||||
raise ValueError()
|
||||
number, prefix = match.group(1, 2)
|
||||
if prefix not in PREFIX_VALUES:
|
||||
raise ValueError()
|
||||
|
||||
return float(number) * PREFIX_VALUES[prefix]
|
||||
|
||||
def format(self, bytes):
|
||||
for prefix in PREFIX_CANONICAL:
|
||||
if bytes < BYTES_BASE:
|
||||
if int(bytes) == bytes:
|
||||
return f"{int(bytes)} {prefix}B"
|
||||
else:
|
||||
return f"{bytes} {prefix}B"
|
||||
bytes /= BYTES_BASE
|
||||
|
||||
bytes *= BYTES_BASE
|
||||
if int(bytes) == bytes:
|
||||
return f"{int(bytes)} YB"
|
||||
else:
|
||||
return f"{bytes} YB"
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
from .byteformatter import ByteFormatter
|
||||
from .validator import Validator
|
||||
|
||||
|
||||
class BytesizeAsStringValidator(Validator):
|
||||
def __init__(self, name, minimum=None, maximum=None):
|
||||
super().__init__(name)
|
||||
self.min = minimum
|
||||
self.max = maximum
|
||||
|
||||
def validate(self, value):
|
||||
if type(value) is str:
|
||||
value = value.strip()
|
||||
if value is None or (type(value) == str and len(value) == 0):
|
||||
return None
|
||||
try:
|
||||
if type(value) == str:
|
||||
value = ByteFormatter().parse(value)
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
self.raiseForValue(value)
|
||||
|
||||
if self.max is not None and value > self.max:
|
||||
self.raiseForValue(value)
|
||||
if self.min is not None and value < self.min:
|
||||
self.raiseForValue(value)
|
||||
return value
|
||||
|
||||
def formatForUi(self, value):
|
||||
return ByteFormatter().format(value)
|
||||
@@ -0,0 +1,307 @@
|
||||
import json
|
||||
import os
|
||||
import os.path
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
from yarl import URL
|
||||
|
||||
from .settings import _LOOKUP, Setting, _VALIDATORS
|
||||
from ..logger import getLogger
|
||||
from backup.file import JsonFileSaver
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
ALWAYS_KEEP = {
|
||||
Setting.DAYS_BETWEEN_BACKUPS,
|
||||
Setting.MAX_BACKUPS_IN_HA,
|
||||
Setting.MAX_BACKUPS_IN_GOOGLE_DRIVE,
|
||||
}
|
||||
|
||||
KEEP_DEFAULT = {
|
||||
Setting.SEND_ERROR_REPORTS,
|
||||
Setting.IGNORE_UPGRADE_BACKUPS
|
||||
}
|
||||
|
||||
# these are the options that should trigger a restart of the server
|
||||
SERVER_OPTIONS = {
|
||||
Setting.USE_SSL,
|
||||
Setting.REQUIRE_LOGIN,
|
||||
Setting.CERTFILE,
|
||||
Setting.KEYFILE,
|
||||
Setting.EXPOSE_EXTRA_SERVER
|
||||
}
|
||||
|
||||
NON_UI_SETTING = {
|
||||
Setting.SUPERVISOR_URL,
|
||||
Setting.TOKEN_SERVER_HOSTS,
|
||||
Setting.DRIVE_AUTHORIZE_URL,
|
||||
Setting.DRIVE_DEVICE_CODE_URL,
|
||||
Setting.DEFAULT_DRIVE_CLIENT_ID,
|
||||
Setting.NEW_BACKUP_TIMEOUT_SECONDS,
|
||||
Setting.LOG_LEVEL,
|
||||
Setting.CONSOLE_LOG_LEVEL,
|
||||
Setting.DEFAULT_SYNC_INTERVAL_VARIATION,
|
||||
Setting.CACHE_WARMUP_MAX_SECONDS,
|
||||
Setting.CACHE_WARMUP_ERROR_TIMEOUT_SECONDS,
|
||||
Setting.WATCH_BACKUP_DIRECTORY,
|
||||
Setting.TRACE_REQUESTS,
|
||||
Setting.MAX_BACKOFF_SECONDS
|
||||
}
|
||||
|
||||
UPGRADE_OPTIONS = {
|
||||
Setting.DEPRECTAED_MAX_BACKUPS_IN_HA: Setting.MAX_BACKUPS_IN_HA,
|
||||
Setting.DEPRECTAED_MAX_BACKUPS_IN_GOOGLE_DRIVE: Setting.MAX_BACKUPS_IN_GOOGLE_DRIVE,
|
||||
Setting.DEPRECATED_DAYS_BETWEEN_BACKUPS: Setting.DAYS_BETWEEN_BACKUPS,
|
||||
Setting.DEPRECTAED_IGNORE_OTHER_BACKUPS: Setting.IGNORE_OTHER_BACKUPS,
|
||||
Setting.DEPRECTAED_IGNORE_UPGRADE_BACKUPS: Setting.IGNORE_UPGRADE_BACKUPS,
|
||||
Setting.DEPRECTAED_DELETE_BEFORE_NEW_BACKUP: Setting.DELETE_BEFORE_NEW_BACKUP,
|
||||
Setting.DEPRECTAED_BACKUP_NAME: Setting.BACKUP_NAME,
|
||||
Setting.DEPRECTAED_BACKUP_TIME_OF_DAY: Setting.BACKUP_TIME_OF_DAY,
|
||||
Setting.DEPRECTAED_SPECIFY_BACKUP_FOLDER: Setting.SPECIFY_BACKUP_FOLDER,
|
||||
Setting.DEPRECTAED_NOTIFY_FOR_STALE_BACKUPS: Setting.NOTIFY_FOR_STALE_BACKUPS,
|
||||
Setting.DEPRECTAED_ENABLE_BACKUP_STALE_SENSOR: Setting.ENABLE_BACKUP_STALE_SENSOR,
|
||||
Setting.DEPRECTAED_ENABLE_BACKUP_STATE_SENSOR: Setting.ENABLE_BACKUP_STATE_SENSOR,
|
||||
Setting.DEPRECATED_BACKUP_PASSWORD: Setting.BACKUP_PASSWORD
|
||||
}
|
||||
|
||||
EMPTY_IS_DEFAULT = {
|
||||
Setting.ACCENT_COLOR,
|
||||
Setting.BACKGROUND_COLOR,
|
||||
}
|
||||
|
||||
|
||||
class GenConfig():
|
||||
def __init__(self, days=0, weeks=0, months=0, years=0, day_of_week='mon', day_of_month=1, day_of_year=1, aggressive=False):
|
||||
self.days = days
|
||||
self.weeks = weeks
|
||||
self.months = months
|
||||
self.years = years
|
||||
self.day_of_week = day_of_week
|
||||
self.day_of_month = day_of_month
|
||||
self.day_of_year = day_of_year
|
||||
self.aggressive = aggressive
|
||||
self._config_was_upgraded = False
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Overrides the default implementation"""
|
||||
if isinstance(other, GenConfig):
|
||||
return self.__dict__ == other.__dict__
|
||||
return NotImplemented
|
||||
|
||||
def __hash__(self):
|
||||
"""Overrides the default implementation"""
|
||||
return hash(tuple(sorted(self.__dict__.items())))
|
||||
|
||||
|
||||
class Config():
|
||||
@classmethod
|
||||
def fromFile(cls, config_path):
|
||||
return Config(JsonFileSaver.read(config_path))
|
||||
|
||||
@classmethod
|
||||
def withOverrides(cls, overrides):
|
||||
config = Config()
|
||||
for key in overrides.keys():
|
||||
config.override(key, overrides[key])
|
||||
return config
|
||||
|
||||
@classmethod
|
||||
def withFileOverrides(cls, override_path):
|
||||
data = JsonFileSaver.read(override_path)
|
||||
overrides = {}
|
||||
for key in data.keys():
|
||||
overrides[_LOOKUP[key]] = data[key]
|
||||
return Config.withOverrides(overrides)
|
||||
|
||||
@classmethod
|
||||
def fromEnvironment(cls):
|
||||
config = {}
|
||||
for key in os.environ:
|
||||
if key in _LOOKUP:
|
||||
config[_LOOKUP[key]] = _VALIDATORS[_LOOKUP[key]].validate(os.environ[key])
|
||||
elif str.lower(key) in _LOOKUP:
|
||||
config[_LOOKUP[str.lower(key)]] = _VALIDATORS[_LOOKUP[str.lower(key)]].validate(os.environ[key])
|
||||
return Config(config)
|
||||
|
||||
def __init__(self, data=None):
|
||||
self.overrides = {}
|
||||
if data is None:
|
||||
self.config = {}
|
||||
else:
|
||||
self.config = data
|
||||
self._legacy_ignored_behavior = False
|
||||
self._subscriptions = []
|
||||
self._clientIdentifier = None
|
||||
self.retained = self._loadRetained()
|
||||
self._gen_config_cache = self.getGenerationalConfig()
|
||||
|
||||
# Tracks when hosts have been seen to be offline to retry on different hosts.
|
||||
self._commFailure = {}
|
||||
|
||||
def getConfigFor(self, options):
|
||||
new_config = Config()
|
||||
new_config.overrides = self.overrides.copy()
|
||||
new_config.update(options)
|
||||
return new_config
|
||||
|
||||
def validateUpdate(self, additions):
|
||||
new_config = self.config.copy()
|
||||
new_config.update(additions)
|
||||
validated, upgraded = self.validate(new_config)
|
||||
return validated
|
||||
|
||||
def validate(self, new_config) -> Dict[str, Any]:
|
||||
final_config = {}
|
||||
|
||||
upgraded = False
|
||||
# validate each item
|
||||
for key in new_config:
|
||||
if type(key) == str:
|
||||
if key not in _LOOKUP:
|
||||
# its not in the schema, just ignore it
|
||||
continue
|
||||
setting = _LOOKUP[key]
|
||||
else:
|
||||
setting = key
|
||||
|
||||
value = setting.validator().validate(new_config[key])
|
||||
if setting in UPGRADE_OPTIONS:
|
||||
upgraded = True
|
||||
if isinstance(value, str) and len(value) == 0 and setting in EMPTY_IS_DEFAULT:
|
||||
value = setting.default()
|
||||
if value is not None and (setting in KEEP_DEFAULT or value != setting.default()):
|
||||
if setting in UPGRADE_OPTIONS and (UPGRADE_OPTIONS[setting] not in new_config or new_config[UPGRADE_OPTIONS[setting]] == UPGRADE_OPTIONS[setting].default()):
|
||||
upgraded = True
|
||||
final_config[UPGRADE_OPTIONS[setting]] = value
|
||||
elif setting not in UPGRADE_OPTIONS:
|
||||
final_config[setting] = value
|
||||
|
||||
if upgraded:
|
||||
final_config[Setting.CALL_BACKUP_SNAPSHOT] = True
|
||||
|
||||
# add in non-ui settings
|
||||
for setting in NON_UI_SETTING:
|
||||
if self.get(setting) != setting.default() and not (setting in new_config or setting.key in new_config) and setting not in self.overrides:
|
||||
final_config[setting] = self.get(setting)
|
||||
|
||||
# add defaults
|
||||
for key in ALWAYS_KEEP:
|
||||
if key not in final_config:
|
||||
final_config[key] = key.default()
|
||||
|
||||
if not final_config.get(Setting.USE_SSL, False):
|
||||
for key in [Setting.CERTFILE, Setting.KEYFILE]:
|
||||
if key in final_config:
|
||||
del final_config[key]
|
||||
|
||||
return final_config, upgraded
|
||||
|
||||
def update(self, new_config):
|
||||
validated, upgraded = self.validate(new_config)
|
||||
self._config_was_upgraded = upgraded
|
||||
self.config = validated
|
||||
self._gen_config_cache = self.getGenerationalConfig()
|
||||
for sub in self._subscriptions:
|
||||
sub()
|
||||
|
||||
def getServerOptions(self):
|
||||
ret = {}
|
||||
for setting in SERVER_OPTIONS:
|
||||
ret[setting] = self.get(setting)
|
||||
return ret
|
||||
|
||||
def subscribe(self, func):
|
||||
self._subscriptions.append(func)
|
||||
|
||||
def clientIdentifier(self) -> str:
|
||||
if self._clientIdentifier is None:
|
||||
try:
|
||||
if JsonFileSaver.exists(self.get(Setting.ID_FILE_PATH)):
|
||||
self._clientIdentifier = JsonFileSaver.read(self.get(Setting.ID_FILE_PATH))['id']
|
||||
else:
|
||||
self._clientIdentifier = str(uuid.uuid4())
|
||||
JsonFileSaver.write(self.get(Setting.ID_FILE_PATH), {'id': self._clientIdentifier})
|
||||
except Exception:
|
||||
self._clientIdentifier = str(uuid.uuid4())
|
||||
return self._clientIdentifier
|
||||
|
||||
def getGenerationalConfig(self) -> Optional[Dict[str, Any]]:
|
||||
days = self.get(Setting.GENERATIONAL_DAYS)
|
||||
weeks = self.get(Setting.GENERATIONAL_WEEKS)
|
||||
months = self.get(Setting.GENERATIONAL_MONTHS)
|
||||
years = self.get(Setting.GENERATIONAL_YEARS)
|
||||
if days + weeks + months + years == 0:
|
||||
return None
|
||||
base = GenConfig(
|
||||
days=days,
|
||||
weeks=weeks,
|
||||
months=months,
|
||||
years=years,
|
||||
day_of_week=self.get(Setting.GENERATIONAL_DAY_OF_WEEK),
|
||||
day_of_month=self.get(Setting.GENERATIONAL_DAY_OF_MONTH),
|
||||
day_of_year=self.get(Setting.GENERATIONAL_DAY_OF_YEAR),
|
||||
aggressive=self.get(Setting.GENERATIONAL_DELETE_EARLY)
|
||||
)
|
||||
if base.days <= 1:
|
||||
# must always be >= 1, otherwise we'll just create and delete backups constantly.
|
||||
base.days = 1
|
||||
return base
|
||||
|
||||
def _loadRetained(self) -> List[str]:
|
||||
if JsonFileSaver.exists(self.get(Setting.RETAINED_FILE_PATH)):
|
||||
try:
|
||||
return JsonFileSaver.read(self.get(Setting.RETAINED_FILE_PATH))['retained']
|
||||
except json.decoder.JSONDecodeError:
|
||||
logger.error("Unable to parse retained backup settings")
|
||||
return []
|
||||
return []
|
||||
|
||||
def isRetained(self, slug):
|
||||
return slug in self.retained
|
||||
|
||||
def setRetained(self, slug, retain):
|
||||
if retain and slug not in self.retained:
|
||||
self.retained.append(slug)
|
||||
JsonFileSaver.write(self.get(Setting.RETAINED_FILE_PATH), {'retained': self.retained})
|
||||
elif not retain and slug in self.retained:
|
||||
self.retained.remove(slug)
|
||||
JsonFileSaver.write(self.get(Setting.RETAINED_FILE_PATH), {'retained': self.retained})
|
||||
|
||||
def isExplicit(self, setting):
|
||||
return setting in self.config or setting.value in self.config
|
||||
|
||||
def override(self, setting: Setting, value):
|
||||
self.overrides[setting] = value
|
||||
return self
|
||||
|
||||
def get(self, setting: Setting) -> Any:
|
||||
if setting in self.overrides:
|
||||
return self.overrides[setting]
|
||||
if setting in self.config:
|
||||
return self.config[setting]
|
||||
if setting.key() in self.config:
|
||||
return self.config[setting.key()]
|
||||
else:
|
||||
if setting == Setting.IGNORE_UPGRADE_BACKUPS and self._legacy_ignored_behavior:
|
||||
# Use the old behavior, rather than the new one
|
||||
return False
|
||||
return setting.default()
|
||||
|
||||
def getForUi(self, setting: Setting):
|
||||
return _VALIDATORS[setting].formatForUi(self.get(setting))
|
||||
|
||||
def getTokenServers(self, path: str = "") -> List[URL]:
|
||||
return list(map(lambda s: URL(s).with_path(path), self.get(Setting.TOKEN_SERVER_HOSTS).split(",")))
|
||||
|
||||
def mustSaveUpgradeChanges(self):
|
||||
return self._config_was_upgraded
|
||||
|
||||
def getAllConfig(self) -> Dict[Setting, Any]:
|
||||
return self.config.copy()
|
||||
|
||||
def persistedChanges(self):
|
||||
self._config_was_upgraded = False
|
||||
|
||||
def useLegacyIgnoredBehavior(self, value: bool):
|
||||
"""If the user upgrades from an old version and hasn't explicitely said they want to include upgrade backups, then this reverts them to the old behavior where they aren't ignored"""
|
||||
self._legacy_ignored_behavior = value
|
||||
@@ -0,0 +1,13 @@
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class CreateOptions(object):
|
||||
def __init__(self, when: datetime, name_template: str, retain_sources: Dict[str, bool] = {}, note: str = None):
|
||||
self.when: datetime = when
|
||||
self.name_template: str = name_template
|
||||
self.retain_sources: Dict[str, bool] = retain_sources
|
||||
self.note = note
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
from datetime import timedelta
|
||||
from .durationparser import DurationParser
|
||||
from .validator import Validator
|
||||
|
||||
|
||||
class DurationAsStringValidator(Validator):
|
||||
def __init__(self, name, minimum=None, maximum=None, base_seconds=1, default_as_empty=None):
|
||||
super().__init__(name)
|
||||
self.min = minimum
|
||||
self.max = maximum
|
||||
self.base_seconds = base_seconds
|
||||
self.default_as_empty = default_as_empty
|
||||
|
||||
def validate(self, value):
|
||||
if value is None or (type(value) == str and len(value) == 0):
|
||||
return None
|
||||
try:
|
||||
if type(value) == str:
|
||||
if self.default_as_empty is not None and value == "":
|
||||
value = self.default_as_empty
|
||||
else:
|
||||
value = DurationParser().parse(value).total_seconds() / self.base_seconds
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
self.raiseForValue(value)
|
||||
|
||||
if self.max is not None and value > self.max:
|
||||
self.raiseForValue(value)
|
||||
if self.min is not None and value < self.min:
|
||||
self.raiseForValue(value)
|
||||
return value
|
||||
|
||||
def formatForUi(self, value):
|
||||
if self.default_as_empty is not None and value == self.default_as_empty:
|
||||
return ""
|
||||
else:
|
||||
return DurationParser().format(timedelta(seconds=value * self.base_seconds))
|
||||
@@ -0,0 +1,80 @@
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from injector import inject, singleton
|
||||
|
||||
SECOND_IDENTIFIERS = ["s", "sec", "secs", "second", "seconds"]
|
||||
MINUTE_IDENTIFIERS = ["m", "min", "mins", "minute", "minutes"]
|
||||
HOUR_IDENTIFIERS = ["h", "hr", "hour", "hours"]
|
||||
DAY_IDENTIFIERS = ["d", "day", "days"]
|
||||
NUMBER_REGEX = "^([0-9]*[.])?[0-9]+"
|
||||
VALID_REGEX = "^([ ]*([0-9]*[.])?[0-9]+[ ]*(seconds|second|secs|sec|s|minutes|minute|mins|min|m|hours|hour|hr|h|days|day|d)?[ ,]*)*"
|
||||
|
||||
|
||||
@singleton
|
||||
class DurationParser():
|
||||
@inject
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def parse(self, source: str):
|
||||
source = source.lower()
|
||||
total_match = re.match(VALID_REGEX, source)
|
||||
if not total_match or total_match.group(0) != source:
|
||||
raise ValueError()
|
||||
parts = source.split()
|
||||
i = 0
|
||||
total = timedelta(seconds=0)
|
||||
while (i < len(parts)):
|
||||
part = parts[i].strip().strip(',')
|
||||
match = re.match(NUMBER_REGEX, part)
|
||||
i += 1
|
||||
if not match:
|
||||
raise ValueError()
|
||||
length = float(match.group(0))
|
||||
if match.group(0) == part:
|
||||
|
||||
if i < len(parts):
|
||||
next_part = parts[i].strip().strip(',')
|
||||
if next_part in SECOND_IDENTIFIERS or next_part in MINUTE_IDENTIFIERS or next_part in HOUR_IDENTIFIERS or next_part in DAY_IDENTIFIERS:
|
||||
identifier = next_part
|
||||
i += 1
|
||||
else:
|
||||
identifier = SECOND_IDENTIFIERS[0]
|
||||
else:
|
||||
identifier = "s"
|
||||
else:
|
||||
identifier = part[len(match.group(0)):]
|
||||
if identifier in SECOND_IDENTIFIERS:
|
||||
total += timedelta(seconds=length)
|
||||
elif identifier in MINUTE_IDENTIFIERS:
|
||||
total += timedelta(minutes=length)
|
||||
elif identifier in HOUR_IDENTIFIERS:
|
||||
total += timedelta(hours=length)
|
||||
elif identifier in DAY_IDENTIFIERS:
|
||||
total += timedelta(days=length)
|
||||
else:
|
||||
raise ValueError()
|
||||
return total
|
||||
|
||||
def format(self, duration: timedelta):
|
||||
parts = []
|
||||
if duration >= timedelta(days=1):
|
||||
days = int(duration.days)
|
||||
parts.append("{} days".format(days))
|
||||
duration = duration - timedelta(days=days)
|
||||
if duration >= timedelta(hours=1):
|
||||
hours = int(duration.seconds / (60 * 60))
|
||||
parts.append("{} hours".format(hours))
|
||||
duration = duration - timedelta(hours=hours)
|
||||
if duration >= timedelta(minutes=1):
|
||||
minutes = int(duration.seconds / 60)
|
||||
parts.append("{} minutes".format(minutes))
|
||||
duration = duration - timedelta(minutes=minutes)
|
||||
if duration >= timedelta(seconds=1):
|
||||
seconds = int(duration.seconds)
|
||||
parts.append("{} seconds".format(seconds))
|
||||
duration = duration - timedelta(seconds=seconds)
|
||||
if len(parts) > 0:
|
||||
return ", ".join(parts)
|
||||
else:
|
||||
return "0 seconds"
|
||||
@@ -0,0 +1,25 @@
|
||||
from .validator import Validator
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class FloatValidator(Validator):
|
||||
def __init__(self, name, minimum=None, maximum=None):
|
||||
super().__init__(name)
|
||||
self.min = minimum
|
||||
self.max = maximum
|
||||
|
||||
def validate(self, value):
|
||||
if value is None or (type(value) == str and len(value) == 0):
|
||||
return None
|
||||
try:
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
self.raiseForValue(value)
|
||||
|
||||
if self.max is not None and value > self.max:
|
||||
self.raiseForValue(value)
|
||||
if self.min is not None and value < self.min:
|
||||
self.raiseForValue(value)
|
||||
return value
|
||||
@@ -0,0 +1,25 @@
|
||||
from .validator import Validator
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class IntValidator(Validator):
|
||||
def __init__(self, name, minimum=None, maximum=None):
|
||||
super().__init__(name)
|
||||
self.min = minimum
|
||||
self.max = maximum
|
||||
|
||||
def validate(self, value):
|
||||
if value is None or (type(value) == str and len(value) == 0):
|
||||
return None
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
self.raiseForValue(value)
|
||||
|
||||
if self.max is not None and value > self.max:
|
||||
self.raiseForValue(value)
|
||||
if self.min is not None and value < self.min:
|
||||
self.raiseForValue(value)
|
||||
return value
|
||||
@@ -0,0 +1,15 @@
|
||||
from .validator import Validator
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class ListValidator(Validator):
|
||||
def __init__(self, name, values):
|
||||
super().__init__(name)
|
||||
self.values = values
|
||||
|
||||
def validate(self, value):
|
||||
if value not in self.values:
|
||||
self.raiseForValue(value)
|
||||
return value
|
||||
@@ -0,0 +1,19 @@
|
||||
from .validator import Validator
|
||||
import re
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class RegexValidator(Validator):
|
||||
def __init__(self, name, regex):
|
||||
super().__init__(name)
|
||||
self.re = re.compile(regex)
|
||||
|
||||
def validate(self, value):
|
||||
if value is None or (type(value) == str and len(value) == 0):
|
||||
return ""
|
||||
value = str(value)
|
||||
if not self.re.match(value):
|
||||
self.raiseForValue(value)
|
||||
return value
|
||||
@@ -0,0 +1,517 @@
|
||||
import json
|
||||
from enum import Enum, unique
|
||||
from os.path import abspath, join
|
||||
|
||||
from .boolvalidator import BoolValidator
|
||||
from .floatvalidator import FloatValidator
|
||||
from .intvalidator import IntValidator
|
||||
from .regexvalidator import RegexValidator
|
||||
from .stringvalidator import StringValidator
|
||||
from .listvalidator import ListValidator
|
||||
from .durationasstringvalidator import DurationAsStringValidator
|
||||
from .bytesizeasstringvalidator import BytesizeAsStringValidator
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@unique
|
||||
class Setting(Enum):
|
||||
MAX_BACKUPS_IN_HA = "max_backups_in_ha"
|
||||
MAX_BACKUPS_IN_GOOGLE_DRIVE = "max_backups_in_google_drive"
|
||||
DAYS_BETWEEN_BACKUPS = "days_between_backups"
|
||||
IGNORE_OTHER_BACKUPS = "ignore_other_backups"
|
||||
IGNORE_UPGRADE_BACKUPS = "ignore_upgrade_backups"
|
||||
DELETE_IGNORED_AFTER_DAYS = "delete_ignored_after_days"
|
||||
DELETE_BEFORE_NEW_BACKUP = "delete_before_new_backup"
|
||||
BACKUP_NAME = "backup_name"
|
||||
BACKUP_TIME_OF_DAY = "backup_time_of_day"
|
||||
SPECIFY_BACKUP_FOLDER = "specify_backup_folder"
|
||||
NOTIFY_FOR_STALE_BACKUPS = "notify_for_stale_backups"
|
||||
ENABLE_BACKUP_STALE_SENSOR = "enable_backup_stale_sensor"
|
||||
ENABLE_BACKUP_STATE_SENSOR = "enable_backup_state_sensor"
|
||||
BACKUP_PASSWORD = "backup_password"
|
||||
BACKUP_STORAGE = "backup_storage"
|
||||
CALL_BACKUP_SNAPSHOT = "call_backup_snapshot"
|
||||
|
||||
# Basic backup settings
|
||||
WARN_FOR_LOW_SPACE = "warn_for_low_space"
|
||||
LOW_SPACE_THRESHOLD = "low_space_threshold"
|
||||
DELETE_AFTER_UPLOAD = "delete_after_upload"
|
||||
|
||||
# generational settings
|
||||
GENERATIONAL_DAYS = "generational_days"
|
||||
GENERATIONAL_WEEKS = "generational_weeks"
|
||||
GENERATIONAL_MONTHS = "generational_months"
|
||||
GENERATIONAL_YEARS = "generational_years"
|
||||
GENERATIONAL_DAY_OF_WEEK = "generational_day_of_week"
|
||||
GENERATIONAL_DAY_OF_MONTH = "generational_day_of_month"
|
||||
GENERATIONAL_DAY_OF_YEAR = "generational_day_of_year"
|
||||
GENERATIONAL_DELETE_EARLY = "generational_delete_early"
|
||||
|
||||
# Partial backups
|
||||
EXCLUDE_FOLDERS = "exclude_folders"
|
||||
EXCLUDE_ADDONS = "exclude_addons"
|
||||
|
||||
STOP_ADDONS = "stop_addons"
|
||||
DISABLE_WATCHDOG_WHEN_STOPPING = "disable_watchdog_when_stopping"
|
||||
|
||||
# UI Server Options
|
||||
USE_SSL = "use_ssl"
|
||||
CERTFILE = "certfile"
|
||||
KEYFILE = "keyfile"
|
||||
INGRESS_PORT = "ingress_port"
|
||||
PORT = "port"
|
||||
REQUIRE_LOGIN = "require_login"
|
||||
EXPOSE_EXTRA_SERVER = "expose_extra_server"
|
||||
|
||||
# Add-on options
|
||||
VERBOSE = "verbose"
|
||||
SEND_ERROR_REPORTS = "send_error_reports"
|
||||
CONFIRM_MULTIPLE_DELETES = "confirm_multiple_deletes"
|
||||
ENABLE_DRIVE_UPLOAD = "enable_drive_upload"
|
||||
WATCH_BACKUP_DIRECTORY = "watch_backup_directory"
|
||||
TRACE_REQUESTS = "trace_requests"
|
||||
|
||||
# Theme Settings
|
||||
BACKGROUND_COLOR = "background_color"
|
||||
ACCENT_COLOR = "accent_color"
|
||||
|
||||
# Network and dns stuff
|
||||
DRIVE_EXPERIMENTAL = "drive_experimental"
|
||||
DRIVE_IPV4 = "drive_ipv4"
|
||||
IGNORE_IPV6_ADDRESSES = "ignore_ipv6_addresses"
|
||||
GOOGLE_DRIVE_TIMEOUT_SECONDS = "google_drive_timeout_seconds"
|
||||
GOOGLE_DRIVE_PAGE_SIZE = "google_drive_page_size"
|
||||
ALTERNATE_DNS_SERVERS = "alternate_dns_servers"
|
||||
DEFAULT_DRIVE_CLIENT_ID = "default_drive_client_id"
|
||||
DEFAULT_DRIVE_CLIENT_SECRET = "default_drive_client_secret"
|
||||
DRIVE_PICKER_API_KEY = "drive_picker_api_key"
|
||||
MAXIMUM_UPLOAD_CHUNK_BYTES = "maximum_upload_chunk_bytes"
|
||||
|
||||
# Files and folders
|
||||
FOLDER_FILE_PATH = "folder_file_path"
|
||||
CREDENTIALS_FILE_PATH = "credentials_file_path"
|
||||
RETAINED_FILE_PATH = "retained_file_path"
|
||||
SECRETS_FILE_PATH = "secrets_file_path"
|
||||
BACKUP_DIRECTORY_PATH = "backup_directory_path"
|
||||
INGRESS_TOKEN_FILE_PATH = "ingress_token_file_path"
|
||||
CONFIG_FILE_PATH = "config_file_path"
|
||||
ID_FILE_PATH = "id_file_path"
|
||||
DATA_CACHE_FILE_PATH = "data_cache_file_path"
|
||||
|
||||
# endpoints
|
||||
AUTHORIZATION_HOST = "authorization_host"
|
||||
TOKEN_SERVER_HOSTS = "token_server_hosts"
|
||||
SUPERVISOR_URL = "supervisor_url"
|
||||
DRIVE_URL = "drive_url"
|
||||
SUPERVISOR_TOKEN = "hassio_header"
|
||||
DRIVE_HOST_NAME = "drive_host_name"
|
||||
DRIVE_REFRESH_URL = "drive_refresh_url"
|
||||
DRIVE_AUTHORIZE_URL = "drive_authorize_url"
|
||||
DRIVE_DEVICE_CODE_URL = "drive_device_code_url"
|
||||
DRIVE_TOKEN_URL = "drive_token_url"
|
||||
SAVE_DRIVE_CREDS_PATH = "save_drive_creds_path"
|
||||
STOP_ADDON_STATE_PATH = "stop_addon_state_path"
|
||||
|
||||
# Timing and timeouts
|
||||
MAX_SYNC_INTERVAL_SECONDS = "max_sync_interval_seconds"
|
||||
DEFAULT_SYNC_INTERVAL_VARIATION = "default_sync_interval_variation"
|
||||
BACKUP_STALE_SECONDS = "backup_stale_seconds"
|
||||
PENDING_BACKUP_TIMEOUT_SECONDS = "pending_backup_timeout_seconds"
|
||||
FAILED_BACKUP_TIMEOUT_SECONDS = "failed_backup_timeout_seconds"
|
||||
NEW_BACKUP_TIMEOUT_SECONDS = "new_backup_timeout_seconds"
|
||||
DOWNLOAD_TIMEOUT_SECONDS = "download_timeout_seconds"
|
||||
DEFAULT_CHUNK_SIZE = "default_chunk_size"
|
||||
DEBUGGER_PORT = "debugger_port"
|
||||
SERVER_PROJECT_ID = "server_project_id"
|
||||
LOG_LEVEL = "log_level"
|
||||
CONSOLE_LOG_LEVEL = "console_log_level"
|
||||
BACKUP_STARTUP_DELAY_MINUTES = "backup_startup_delay_minutes"
|
||||
EXCHANGER_TIMEOUT_SECONDS = "exchanger_timeout_seconds"
|
||||
HA_REPORTING_INTERVAL_SECONDS = "ha_reporting_interval_seconds"
|
||||
LONG_TERM_STALE_BACKUP_SECONDS = "long_term_stale_backup_seconds"
|
||||
PING_TIMEOUT = "ping_timeout"
|
||||
CACHE_WARMUP_MAX_SECONDS = "cache_warmup_max_seconds"
|
||||
CACHE_WARMUP_ERROR_TIMEOUT_SECONDS = "cache_warmup_error_timeout"
|
||||
MAX_BACKOFF_SECONDS = "max_backoff_seconds"
|
||||
|
||||
# Old, deprecated settings
|
||||
DEPRECTAED_MAX_BACKUPS_IN_HA = "max_snapshots_in_hassio"
|
||||
DEPRECTAED_MAX_BACKUPS_IN_GOOGLE_DRIVE = "max_snapshots_in_google_drive"
|
||||
DEPRECATED_DAYS_BETWEEN_BACKUPS = "days_between_snapshots"
|
||||
DEPRECTAED_IGNORE_OTHER_BACKUPS = "ignore_other_snapshots"
|
||||
DEPRECTAED_IGNORE_UPGRADE_BACKUPS = "ignore_upgrade_snapshots"
|
||||
DEPRECTAED_BACKUP_NAME = "snapshot_name"
|
||||
DEPRECTAED_BACKUP_TIME_OF_DAY = "snapshot_time_of_day"
|
||||
DEPRECATED_BACKUP_PASSWORD = "snapshot_password"
|
||||
DEPRECTAED_SPECIFY_BACKUP_FOLDER = "specify_snapshot_folder"
|
||||
DEPRECTAED_DELETE_BEFORE_NEW_BACKUP = "delete_before_new_snapshot"
|
||||
DEPRECTAED_NOTIFY_FOR_STALE_BACKUPS = "notify_for_stale_snapshots"
|
||||
DEPRECTAED_ENABLE_BACKUP_STALE_SENSOR = "enable_snapshot_stale_sensor"
|
||||
DEPRECTAED_ENABLE_BACKUP_STATE_SENSOR = "enable_snapshot_state_sensor"
|
||||
|
||||
def default(self):
|
||||
if "staging" in VERSION and self in _STAGING_DEFAULTS:
|
||||
return _STAGING_DEFAULTS[self]
|
||||
return _DEFAULTS[self]
|
||||
|
||||
def validator(self):
|
||||
return _VALIDATORS[self]
|
||||
|
||||
def key(self):
|
||||
return self.value
|
||||
|
||||
|
||||
_DEFAULTS = {
|
||||
Setting.MAX_BACKUPS_IN_HA: 4,
|
||||
Setting.MAX_BACKUPS_IN_GOOGLE_DRIVE: 4,
|
||||
Setting.DAYS_BETWEEN_BACKUPS: 3,
|
||||
Setting.IGNORE_OTHER_BACKUPS: False,
|
||||
Setting.IGNORE_UPGRADE_BACKUPS: True,
|
||||
Setting.DELETE_IGNORED_AFTER_DAYS: 0,
|
||||
Setting.DELETE_BEFORE_NEW_BACKUP: False,
|
||||
Setting.BACKUP_NAME: "{type} Backup {year}-{month}-{day} {hr24}:{min}:{sec}",
|
||||
Setting.BACKUP_TIME_OF_DAY: "",
|
||||
Setting.SPECIFY_BACKUP_FOLDER: False,
|
||||
Setting.NOTIFY_FOR_STALE_BACKUPS: True,
|
||||
Setting.ENABLE_BACKUP_STALE_SENSOR: True,
|
||||
Setting.ENABLE_BACKUP_STATE_SENSOR: True,
|
||||
Setting.BACKUP_PASSWORD: "",
|
||||
Setting.BACKUP_STORAGE: "",
|
||||
Setting.WATCH_BACKUP_DIRECTORY: True,
|
||||
Setting.TRACE_REQUESTS: False,
|
||||
|
||||
# Basic backup settings
|
||||
Setting.DEPRECTAED_MAX_BACKUPS_IN_HA: 4,
|
||||
Setting.DEPRECTAED_MAX_BACKUPS_IN_GOOGLE_DRIVE: 4,
|
||||
Setting.DEPRECATED_DAYS_BETWEEN_BACKUPS: 3,
|
||||
Setting.DEPRECTAED_IGNORE_OTHER_BACKUPS: False,
|
||||
Setting.DEPRECTAED_IGNORE_UPGRADE_BACKUPS: False,
|
||||
Setting.DEPRECTAED_BACKUP_TIME_OF_DAY: "",
|
||||
Setting.DEPRECTAED_BACKUP_NAME: "{type} Snapshot {year}-{month}-{day} {hr24}:{min}:{sec}",
|
||||
Setting.DEPRECATED_BACKUP_PASSWORD: "",
|
||||
Setting.DEPRECTAED_SPECIFY_BACKUP_FOLDER: False,
|
||||
Setting.WARN_FOR_LOW_SPACE: True,
|
||||
Setting.LOW_SPACE_THRESHOLD: 1024 * 1024 * 1024,
|
||||
Setting.DELETE_AFTER_UPLOAD: False,
|
||||
Setting.DEPRECTAED_DELETE_BEFORE_NEW_BACKUP: False,
|
||||
Setting.CALL_BACKUP_SNAPSHOT: False,
|
||||
|
||||
# Generational backup settings
|
||||
Setting.GENERATIONAL_DAYS: 0,
|
||||
Setting.GENERATIONAL_WEEKS: 0,
|
||||
Setting.GENERATIONAL_MONTHS: 0,
|
||||
Setting.GENERATIONAL_YEARS: 0,
|
||||
Setting.GENERATIONAL_DAY_OF_WEEK: "mon",
|
||||
Setting.GENERATIONAL_DAY_OF_MONTH: 1,
|
||||
Setting.GENERATIONAL_DAY_OF_YEAR: 1,
|
||||
Setting.GENERATIONAL_DELETE_EARLY: False,
|
||||
|
||||
# Partial backup settings
|
||||
Setting.EXCLUDE_FOLDERS: "",
|
||||
Setting.EXCLUDE_ADDONS: "",
|
||||
|
||||
Setting.STOP_ADDONS: "",
|
||||
Setting.DISABLE_WATCHDOG_WHEN_STOPPING: False,
|
||||
|
||||
# UI Server settings
|
||||
Setting.USE_SSL: False,
|
||||
Setting.REQUIRE_LOGIN: False,
|
||||
Setting.EXPOSE_EXTRA_SERVER: False,
|
||||
Setting.CERTFILE: "/ssl/fullchain.pem",
|
||||
Setting.KEYFILE: "/ssl/privkey.pem",
|
||||
Setting.INGRESS_PORT: 8099,
|
||||
Setting.PORT: 1627,
|
||||
|
||||
# Add-on options
|
||||
Setting.DEPRECTAED_NOTIFY_FOR_STALE_BACKUPS: True,
|
||||
Setting.DEPRECTAED_ENABLE_BACKUP_STALE_SENSOR: True,
|
||||
Setting.DEPRECTAED_ENABLE_BACKUP_STATE_SENSOR: True,
|
||||
Setting.SEND_ERROR_REPORTS: False,
|
||||
Setting.VERBOSE: False,
|
||||
Setting.CONFIRM_MULTIPLE_DELETES: True,
|
||||
Setting.ENABLE_DRIVE_UPLOAD: True,
|
||||
|
||||
# Theme Settings
|
||||
Setting.BACKGROUND_COLOR: "",
|
||||
Setting.ACCENT_COLOR: "",
|
||||
|
||||
# Network and DNS settings
|
||||
Setting.ALTERNATE_DNS_SERVERS: "8.8.8.8,8.8.4.4",
|
||||
Setting.DRIVE_EXPERIMENTAL: False,
|
||||
Setting.DRIVE_IPV4: "",
|
||||
Setting.IGNORE_IPV6_ADDRESSES: False,
|
||||
Setting.GOOGLE_DRIVE_TIMEOUT_SECONDS: 180,
|
||||
Setting.GOOGLE_DRIVE_PAGE_SIZE: 100,
|
||||
Setting.MAXIMUM_UPLOAD_CHUNK_BYTES: 10 * 1024 * 1024,
|
||||
|
||||
# Remote endpoints
|
||||
Setting.AUTHORIZATION_HOST: "https://habackup.io",
|
||||
Setting.TOKEN_SERVER_HOSTS: "https://token1.habackup.io,https://habackup.io",
|
||||
Setting.SUPERVISOR_URL: "",
|
||||
Setting.SUPERVISOR_TOKEN: "",
|
||||
Setting.DRIVE_URL: "https://www.googleapis.com",
|
||||
Setting.DRIVE_REFRESH_URL: "https://www.googleapis.com/oauth2/v4/token",
|
||||
Setting.DRIVE_AUTHORIZE_URL: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
Setting.DRIVE_DEVICE_CODE_URL: "https://oauth2.googleapis.com/device/code",
|
||||
Setting.DRIVE_TOKEN_URL: "https://oauth2.googleapis.com/token",
|
||||
Setting.DRIVE_HOST_NAME: "www.googleapis.com",
|
||||
Setting.SAVE_DRIVE_CREDS_PATH: "token",
|
||||
|
||||
# File locations used to store things
|
||||
Setting.FOLDER_FILE_PATH: "/data/folder.dat",
|
||||
Setting.CREDENTIALS_FILE_PATH: "/data/credentials.dat",
|
||||
Setting.BACKUP_DIRECTORY_PATH: "/backup",
|
||||
Setting.RETAINED_FILE_PATH: "/data/retained.json",
|
||||
Setting.SECRETS_FILE_PATH: "/config/secrets.yaml",
|
||||
Setting.INGRESS_TOKEN_FILE_PATH: "/data/ingress.dat",
|
||||
Setting.CONFIG_FILE_PATH: "/data/options.json",
|
||||
Setting.ID_FILE_PATH: "/data/id.json",
|
||||
Setting.STOP_ADDON_STATE_PATH: '/data/stop_addon_state.json',
|
||||
Setting.DATA_CACHE_FILE_PATH: '/data/data_cache.json',
|
||||
|
||||
# Various timeouts and intervals
|
||||
Setting.BACKUP_STALE_SECONDS: 60 * 60 * 3,
|
||||
Setting.PENDING_BACKUP_TIMEOUT_SECONDS: 60 * 60 * 5,
|
||||
Setting.FAILED_BACKUP_TIMEOUT_SECONDS: 60 * 15,
|
||||
Setting.NEW_BACKUP_TIMEOUT_SECONDS: 5,
|
||||
Setting.MAX_SYNC_INTERVAL_SECONDS: 60 * 60 * 3, # 3 hours
|
||||
Setting.DEFAULT_SYNC_INTERVAL_VARIATION: 0.5, # intermittent checkup syncs happen between 1.5 and 3 hours since the last one, randomly
|
||||
Setting.DEFAULT_DRIVE_CLIENT_ID: "933944288016-n35gnn2juc76ub7u5326ls0iaq9dgjgu.apps.googleusercontent.com",
|
||||
Setting.DEFAULT_DRIVE_CLIENT_SECRET: "",
|
||||
Setting.DRIVE_PICKER_API_KEY: "",
|
||||
Setting.DEFAULT_CHUNK_SIZE: 1024 * 1024 * 5,
|
||||
Setting.DOWNLOAD_TIMEOUT_SECONDS: 60,
|
||||
Setting.DEBUGGER_PORT: None,
|
||||
Setting.SERVER_PROJECT_ID: "",
|
||||
Setting.LOG_LEVEL: 'DEBUG',
|
||||
Setting.CONSOLE_LOG_LEVEL: 'INFO',
|
||||
Setting.BACKUP_STARTUP_DELAY_MINUTES: 10,
|
||||
Setting.EXCHANGER_TIMEOUT_SECONDS: 10,
|
||||
Setting.HA_REPORTING_INTERVAL_SECONDS: 10,
|
||||
Setting.LONG_TERM_STALE_BACKUP_SECONDS: 60 * 60 * 24,
|
||||
Setting.PING_TIMEOUT: 5,
|
||||
Setting.CACHE_WARMUP_MAX_SECONDS: 15 * 60, # 30 minutes
|
||||
Setting.CACHE_WARMUP_ERROR_TIMEOUT_SECONDS: 24 * 60 * 60, # 1 day
|
||||
Setting.MAX_BACKOFF_SECONDS: 60 * 60 * 2, # 2 hours
|
||||
}
|
||||
|
||||
_STAGING_DEFAULTS = {
|
||||
Setting.AUTHORIZATION_HOST: "https://dev.habackup.io",
|
||||
Setting.TOKEN_SERVER_HOSTS: "https://token1.dev.habackup.io,https://dev.habackup.io",
|
||||
Setting.DEFAULT_DRIVE_CLIENT_ID: "795575624694-jcdhoh1jr1ngccfsbi2f44arr4jupl79.apps.googleusercontent.com",
|
||||
}
|
||||
|
||||
_CONFIG = {
|
||||
Setting.MAX_BACKUPS_IN_HA: "int(0,)?",
|
||||
Setting.MAX_BACKUPS_IN_GOOGLE_DRIVE: "int(0,)?",
|
||||
Setting.DAYS_BETWEEN_BACKUPS: "float(0,)?",
|
||||
Setting.IGNORE_OTHER_BACKUPS: "bool?",
|
||||
Setting.IGNORE_UPGRADE_BACKUPS: "bool?",
|
||||
Setting.DELETE_IGNORED_AFTER_DAYS: "float(0,)?",
|
||||
Setting.DELETE_BEFORE_NEW_BACKUP: "bool?",
|
||||
Setting.BACKUP_NAME: "str?",
|
||||
Setting.BACKUP_TIME_OF_DAY: "match(^[0-2]\\d:[0-5]\\d$)?",
|
||||
Setting.SPECIFY_BACKUP_FOLDER: "bool?",
|
||||
Setting.NOTIFY_FOR_STALE_BACKUPS: "bool?",
|
||||
Setting.ENABLE_BACKUP_STALE_SENSOR: "bool?",
|
||||
Setting.ENABLE_BACKUP_STATE_SENSOR: "bool?",
|
||||
Setting.BACKUP_PASSWORD: "str?",
|
||||
Setting.BACKUP_STORAGE: "str?",
|
||||
Setting.WATCH_BACKUP_DIRECTORY: "bool?",
|
||||
Setting.TRACE_REQUESTS: "bool?",
|
||||
|
||||
# Basic backup settings
|
||||
Setting.DEPRECTAED_MAX_BACKUPS_IN_HA: "int(0,)?",
|
||||
Setting.DEPRECTAED_MAX_BACKUPS_IN_GOOGLE_DRIVE: "int(0,)?",
|
||||
Setting.DEPRECATED_DAYS_BETWEEN_BACKUPS: "float(0,)?",
|
||||
Setting.DEPRECTAED_IGNORE_OTHER_BACKUPS: "bool?",
|
||||
Setting.DEPRECTAED_IGNORE_UPGRADE_BACKUPS: "bool?",
|
||||
Setting.DEPRECTAED_BACKUP_TIME_OF_DAY: "match(^[0-2]\\d:[0-5]\\d$)?",
|
||||
Setting.DEPRECTAED_BACKUP_NAME: "str?",
|
||||
Setting.DEPRECATED_BACKUP_PASSWORD: "str?",
|
||||
Setting.DEPRECTAED_SPECIFY_BACKUP_FOLDER: "bool?",
|
||||
Setting.WARN_FOR_LOW_SPACE: "bool?",
|
||||
Setting.LOW_SPACE_THRESHOLD: "int(0,)?",
|
||||
Setting.DELETE_AFTER_UPLOAD: "bool?",
|
||||
Setting.DEPRECTAED_DELETE_BEFORE_NEW_BACKUP: "bool?",
|
||||
Setting.CALL_BACKUP_SNAPSHOT: "bool?",
|
||||
|
||||
# Generational backup settings
|
||||
Setting.GENERATIONAL_DAYS: "int(0,)?",
|
||||
Setting.GENERATIONAL_WEEKS: "int(0,)?",
|
||||
Setting.GENERATIONAL_MONTHS: "int(0,)?",
|
||||
Setting.GENERATIONAL_YEARS: "int(0,)?",
|
||||
Setting.GENERATIONAL_DAY_OF_WEEK: "match(^(mon|tue|wed|thu|fri|sat|sun)$)?",
|
||||
Setting.GENERATIONAL_DAY_OF_MONTH: "int(1,31)?",
|
||||
Setting.GENERATIONAL_DAY_OF_YEAR: "int(1,365)?",
|
||||
Setting.GENERATIONAL_DELETE_EARLY: "bool?",
|
||||
|
||||
# Partial backup settings
|
||||
Setting.EXCLUDE_FOLDERS: "str?",
|
||||
Setting.EXCLUDE_ADDONS: "str?",
|
||||
|
||||
Setting.STOP_ADDONS: "str?",
|
||||
Setting.DISABLE_WATCHDOG_WHEN_STOPPING: "bool?",
|
||||
|
||||
# UI Server settings
|
||||
Setting.USE_SSL: "bool?",
|
||||
Setting.REQUIRE_LOGIN: "bool?",
|
||||
Setting.EXPOSE_EXTRA_SERVER: "bool?",
|
||||
Setting.CERTFILE: "str?",
|
||||
Setting.KEYFILE: "str?",
|
||||
Setting.INGRESS_PORT: "int(0,)?",
|
||||
Setting.PORT: "int(0,)?",
|
||||
|
||||
# Add-on options
|
||||
Setting.DEPRECTAED_NOTIFY_FOR_STALE_BACKUPS: "bool?",
|
||||
Setting.DEPRECTAED_ENABLE_BACKUP_STALE_SENSOR: "bool?",
|
||||
Setting.DEPRECTAED_ENABLE_BACKUP_STATE_SENSOR: "bool?",
|
||||
Setting.SEND_ERROR_REPORTS: "bool?",
|
||||
Setting.VERBOSE: "bool?",
|
||||
Setting.CONFIRM_MULTIPLE_DELETES: "bool?",
|
||||
Setting.ENABLE_DRIVE_UPLOAD: "bool?",
|
||||
|
||||
# Theme Settings
|
||||
Setting.BACKGROUND_COLOR: "match(^(#[0-9ABCDEFabcdef]{6}|)$)?",
|
||||
Setting.ACCENT_COLOR: "match(^(#[0-9ABCDEFabcdef]{6}|)$)?",
|
||||
|
||||
# Network and DNS settings
|
||||
Setting.ALTERNATE_DNS_SERVERS: "match(^([0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3})(,[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3})*$)?",
|
||||
Setting.DRIVE_EXPERIMENTAL: "bool?",
|
||||
Setting.DRIVE_IPV4: "match(^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$)?",
|
||||
Setting.IGNORE_IPV6_ADDRESSES: "bool?",
|
||||
Setting.GOOGLE_DRIVE_TIMEOUT_SECONDS: "float(1,)?",
|
||||
Setting.GOOGLE_DRIVE_PAGE_SIZE: "int(1,)?",
|
||||
Setting.MAXIMUM_UPLOAD_CHUNK_BYTES: f"float({1024 * 256},)?",
|
||||
|
||||
# Remote endpoints
|
||||
Setting.AUTHORIZATION_HOST: "url?",
|
||||
Setting.TOKEN_SERVER_HOSTS: "str?",
|
||||
Setting.SUPERVISOR_URL: "url?",
|
||||
Setting.SUPERVISOR_TOKEN: "str?",
|
||||
Setting.DRIVE_URL: "url?",
|
||||
Setting.DRIVE_REFRESH_URL: "url?",
|
||||
Setting.DRIVE_AUTHORIZE_URL: "url?",
|
||||
Setting.DRIVE_DEVICE_CODE_URL: "url?",
|
||||
Setting.DRIVE_TOKEN_URL: "url?",
|
||||
Setting.DRIVE_HOST_NAME: "str?",
|
||||
Setting.SAVE_DRIVE_CREDS_PATH: "str?",
|
||||
|
||||
# File locations used to store things
|
||||
Setting.FOLDER_FILE_PATH: "str?",
|
||||
Setting.CREDENTIALS_FILE_PATH: "str?",
|
||||
Setting.BACKUP_DIRECTORY_PATH: "str?",
|
||||
Setting.RETAINED_FILE_PATH: "str?",
|
||||
Setting.SECRETS_FILE_PATH: "str?",
|
||||
Setting.INGRESS_TOKEN_FILE_PATH: "str?",
|
||||
Setting.CONFIG_FILE_PATH: "str?",
|
||||
Setting.ID_FILE_PATH: "str?",
|
||||
Setting.STOP_ADDON_STATE_PATH: "str?",
|
||||
Setting.DATA_CACHE_FILE_PATH: "str?",
|
||||
|
||||
# Various timeouts and intervals
|
||||
Setting.BACKUP_STALE_SECONDS: "float(0,)?",
|
||||
Setting.PENDING_BACKUP_TIMEOUT_SECONDS: "float(0,)?",
|
||||
Setting.FAILED_BACKUP_TIMEOUT_SECONDS: "float(0,)?",
|
||||
Setting.NEW_BACKUP_TIMEOUT_SECONDS: "float(0,)?",
|
||||
Setting.MAX_SYNC_INTERVAL_SECONDS: "float(300,)?",
|
||||
Setting.DEFAULT_SYNC_INTERVAL_VARIATION: "float(0,1)?",
|
||||
Setting.DEFAULT_DRIVE_CLIENT_ID: "str?",
|
||||
Setting.DEFAULT_DRIVE_CLIENT_SECRET: "str?",
|
||||
Setting.DRIVE_PICKER_API_KEY: "str?",
|
||||
Setting.DEFAULT_CHUNK_SIZE: "int(1,)?",
|
||||
Setting.DOWNLOAD_TIMEOUT_SECONDS: "float(0,)?",
|
||||
Setting.DEBUGGER_PORT: "int(100,)?",
|
||||
Setting.SERVER_PROJECT_ID: "str?",
|
||||
Setting.LOG_LEVEL: "list(DEBUG|TRACE|INFO|WARN|CRITICAL|WARNING)?",
|
||||
Setting.CONSOLE_LOG_LEVEL: "list(DEBUG|TRACE|INFO|WARN|CRITICAL|WARNING)?",
|
||||
Setting.BACKUP_STARTUP_DELAY_MINUTES: "float(0,)?",
|
||||
Setting.EXCHANGER_TIMEOUT_SECONDS: "float(0,)?",
|
||||
Setting.HA_REPORTING_INTERVAL_SECONDS: "int(1,)?",
|
||||
Setting.LONG_TERM_STALE_BACKUP_SECONDS: "int(1,)?",
|
||||
Setting.PING_TIMEOUT: "float(0,)?",
|
||||
Setting.CACHE_WARMUP_MAX_SECONDS: "float(0,)",
|
||||
Setting.CACHE_WARMUP_ERROR_TIMEOUT_SECONDS: "float(0,)",
|
||||
Setting.MAX_BACKOFF_SECONDS: "int(3600,)?",
|
||||
}
|
||||
|
||||
PRIVATE = [
|
||||
Setting.DEPRECATED_BACKUP_PASSWORD,
|
||||
Setting.DEPRECTAED_BACKUP_NAME,
|
||||
Setting.BACKUP_PASSWORD,
|
||||
Setting.BACKUP_NAME
|
||||
]
|
||||
|
||||
_LOOKUP = {}
|
||||
_VALIDATORS = {}
|
||||
|
||||
|
||||
def getValidator(name, schema):
|
||||
if schema.endswith("?"):
|
||||
schema = schema[:-1]
|
||||
if schema.startswith("int("):
|
||||
# its a int
|
||||
parts = schema[4:-1]
|
||||
minimum = None
|
||||
maximum = None
|
||||
if parts.endswith(","):
|
||||
minimum = int(parts[0:-1])
|
||||
elif parts.startswith(","):
|
||||
maximum = int(parts[1:])
|
||||
else:
|
||||
digits = parts.split(",")
|
||||
minimum = int(digits[0])
|
||||
maximum = int(digits[1])
|
||||
return IntValidator(name, minimum, maximum)
|
||||
elif schema.startswith("float("):
|
||||
# its a float
|
||||
parts = schema[6:-1]
|
||||
minimum = None
|
||||
maximum = None
|
||||
if parts.endswith(","):
|
||||
minimum = float(parts[0:-1])
|
||||
elif parts.startswith(","):
|
||||
maximum = float(parts[1:])
|
||||
else:
|
||||
digits = parts.split(",")
|
||||
minimum = float(digits[0])
|
||||
maximum = float(digits[1])
|
||||
return FloatValidator(name, minimum, maximum)
|
||||
elif schema.startswith("bool"):
|
||||
# its a bool
|
||||
return BoolValidator(name)
|
||||
elif schema.startswith("str") or schema.startswith("url"):
|
||||
# its a url (treat it just like any string)
|
||||
return StringValidator(name)
|
||||
elif schema.startswith("match("):
|
||||
return RegexValidator(name, schema[6:-1])
|
||||
elif schema.startswith("list("):
|
||||
return ListValidator(name, schema[5:-1].split("|"))
|
||||
else:
|
||||
raise Exception("Invalid schema: " + schema)
|
||||
|
||||
|
||||
# initalize validators
|
||||
for setting in Setting:
|
||||
_LOOKUP[setting.value] = setting
|
||||
|
||||
with open(abspath(join(__file__, "..", "..", "..", "config.json"))) as f:
|
||||
# Thsi is a static file included in the container, so don't worry about using JsonFileLoader
|
||||
addon_config = json.load(f)
|
||||
|
||||
for setting in Setting:
|
||||
_VALIDATORS[setting] = getValidator(setting.value, _CONFIG[setting])
|
||||
for key in addon_config["schema"]:
|
||||
_VALIDATORS[_LOOKUP[key]] = getValidator(key, addon_config["schema"][key])
|
||||
|
||||
_VALIDATORS[Setting.MAX_SYNC_INTERVAL_SECONDS] = DurationAsStringValidator("max_sync_interval_seconds", minimum=1, maximum=None)
|
||||
_VALIDATORS[Setting.HA_REPORTING_INTERVAL_SECONDS] = DurationAsStringValidator("ha_reporting_interval_seconds", minimum=1, maximum=None)
|
||||
_VALIDATORS[Setting.DELETE_IGNORED_AFTER_DAYS] = DurationAsStringValidator("delete_ignored_after_days", minimum=0, maximum=None, base_seconds=60 * 60 * 24, default_as_empty=0)
|
||||
_VALIDATORS[Setting.MAXIMUM_UPLOAD_CHUNK_BYTES] = BytesizeAsStringValidator("maximum_upload_chunk_bytes", minimum=256 * 1024)
|
||||
VERSION = addon_config["version"]
|
||||
|
||||
|
||||
def isStaging():
|
||||
return "staging" in VERSION
|
||||
@@ -0,0 +1,11 @@
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class Startable():
|
||||
async def start(self):
|
||||
pass
|
||||
|
||||
async def stop(self):
|
||||
pass
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
from .validator import Validator
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class StringValidator(Validator):
|
||||
def __init__(self, name):
|
||||
super().__init__(name)
|
||||
|
||||
def validate(self, value):
|
||||
if value is None or (type(value) == str and len(value) == 0):
|
||||
return ""
|
||||
return str(value)
|
||||
@@ -0,0 +1,21 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ..exceptions import InvalidConfigurationValue
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class Validator(ABC):
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
@abstractmethod
|
||||
def validate(self, value):
|
||||
return True
|
||||
|
||||
def raiseForValue(self, value):
|
||||
raise InvalidConfigurationValue(self.name, str(value))
|
||||
|
||||
def formatForUi(self, value):
|
||||
return value
|
||||
@@ -0,0 +1,87 @@
|
||||
STAGING_KEY = ".staging."
|
||||
EXPECTED_VERISON_CHARS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.']
|
||||
|
||||
class Version:
|
||||
def __init__(self, *args):
|
||||
self._identifiers = args
|
||||
self.staging = False
|
||||
|
||||
@classmethod
|
||||
def default(cls):
|
||||
return Version(0)
|
||||
|
||||
@classmethod
|
||||
def max(cls):
|
||||
return Version(99999999)
|
||||
|
||||
@classmethod
|
||||
def parse(cls, version: str):
|
||||
staging_version = None
|
||||
if STAGING_KEY in version:
|
||||
index = version.find(STAGING_KEY)
|
||||
staging_version = int(version[index + len(STAGING_KEY):])
|
||||
version = version[0:index]
|
||||
version = Version._removeUnexpected(version)
|
||||
parts = []
|
||||
for part in version.split("."):
|
||||
if len(part) > 0:
|
||||
parts.append(int(part))
|
||||
if staging_version is not None:
|
||||
parts.append(staging_version)
|
||||
if len(parts) == 0:
|
||||
parts.append(0)
|
||||
ret = Version(*parts)
|
||||
if staging_version is not None:
|
||||
ret.staging = True
|
||||
return ret
|
||||
|
||||
@classmethod
|
||||
def _removeUnexpected(cls, version: str):
|
||||
ret = ""
|
||||
for c in version:
|
||||
if c in EXPECTED_VERISON_CHARS:
|
||||
ret += c
|
||||
while ".." in ret:
|
||||
ret = ret.replace("..", ".")
|
||||
return ret
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._identifiers[key]
|
||||
|
||||
def length(self):
|
||||
return len(self._identifiers)
|
||||
|
||||
def _compare(self, other):
|
||||
i = 0
|
||||
while(i < min(self.length(), other.length())):
|
||||
if self[i] < other[i]:
|
||||
return -1
|
||||
if self[i] > other[i]:
|
||||
return 1
|
||||
i += 1
|
||||
if self.length() < other.length():
|
||||
return -1
|
||||
if self.length() > other.length():
|
||||
return 1
|
||||
return 0
|
||||
|
||||
def __lt__(self, other):
|
||||
return self._compare(other) < 0
|
||||
|
||||
def __le__(self, other):
|
||||
return self._compare(other) <= 0
|
||||
|
||||
def __eq__(self, other):
|
||||
return self._compare(other) == 0
|
||||
|
||||
def __ne__(self, other):
|
||||
return self._compare(other) != 0
|
||||
|
||||
def __gt__(self, other):
|
||||
return self._compare(other) > 0
|
||||
|
||||
def __ge__(self, other):
|
||||
return self._compare(other) >= 0
|
||||
|
||||
def __str__(self):
|
||||
return ".".join(str(i) for i in self._identifiers)
|
||||
@@ -0,0 +1,132 @@
|
||||
|
||||
SOURCE_GOOGLE_DRIVE = "GoogleDrive"
|
||||
SOURCE_HA = "HomeAssistant"
|
||||
|
||||
ERROR_PLEASE_WAIT = "please_wait"
|
||||
ERROR_NOT_UPLOADABLE = "not_uploadable"
|
||||
ERROR_NO_BACKUP = "invalid_slug"
|
||||
ERROR_CREDS_EXPIRED = "creds_bad"
|
||||
ERROR_UPLOAD_FAILED = "upload_failed"
|
||||
ERROR_BAD_PASSWORD_KEY = "password_key_invalid"
|
||||
ERROR_BACKUP_IN_PROGRESS = "backup_in_progress"
|
||||
ERROR_PROTOCOL = "protocol_error"
|
||||
ERROR_LOGIC = "logic_error"
|
||||
ERROR_INVALID_CONFIG = "illegal_config"
|
||||
ERROR_DRIVE_FULL = "drive_full"
|
||||
ERROR_GOOGLE_DNS = "google_dns"
|
||||
ERROR_GOOGLE_CONNECT = "google_cant_connect"
|
||||
ERROR_GOOGLE_INTERNAL = "google_server_error"
|
||||
ERROR_GOOGLE_SESSION = "google_session_expired"
|
||||
ERROR_GOOGLE_TIMEOUT = "google_timeout"
|
||||
ERROR_GOOGLE_UNEXPECTED = "google_unexpected"
|
||||
ERROR_HA_DELETE_ERROR = "delete_error"
|
||||
ERROR_MULTIPLE_DELETES = "multiple_deletes"
|
||||
ERROR_SUPERVISOR_UNEXPECTED = "supervisor_unexpected"
|
||||
ERROR_SUPERVISOR_TIMEOUT = "supervisor_timeout"
|
||||
ERROR_SUPERVISOR_FILE_SYSTEM = "supervisor_fs_error"
|
||||
ERROR_GOOGLE_CRED_PROCESS = "unable_to_make_creds"
|
||||
|
||||
ERROR_EXISTING_FOLDER = "existing_backup_folder"
|
||||
ERROR_BACKUP_FOLDER_MISSING = "backup_folder_missing"
|
||||
CHOOSE_BACKUP_FOLDER = "choose_backup_folder"
|
||||
ERROR_BACKUP_FOLDER_INACCESSIBLE = "backup_folder_inaccessible"
|
||||
ERROR_LOW_SPACE = "low_space"
|
||||
LOG_IN_TO_DRIVE = "log_in_to_drive"
|
||||
SUPERVISOR_PERMISSION = "supervisor_permission"
|
||||
|
||||
# Network storage errors
|
||||
UNKONWN_NETWORK_STORAGE = "unknown_network_storage"
|
||||
INACTIVE_NETWORK_STORAGE = "inactive_network_storage"
|
||||
|
||||
# these keys are necessary because they use the name "snapshot" in non-user-visible
|
||||
# places persisted outside the codebase. They can't be changed without an upgrade path.
|
||||
NECESSARY_OLD_BACKUP_NAME = "snapshot"
|
||||
NECESSARY_OLD_BACKUP_PLURAL_NAME = "snapshots"
|
||||
NECESSARY_OLD_SUPERVISOR_URL = "http://hassio"
|
||||
NECESSARY_PROP_KEY_SLUG = "snapshot_slug"
|
||||
NECESSARY_PROP_KEY_DATE = "snapshot_date"
|
||||
NECESSARY_PROP_KEY_NAME = "snapshot_name"
|
||||
PROP_NOTE = "note"
|
||||
|
||||
DRIVE_FOLDER_URL_FORMAT = "https://drive.google.com/drive/u/0/folders/{0}"
|
||||
GITHUB_ISSUE_URL = "https://github.com/sabeechen/hassio-google-drive-backup/issues/new?labels[]=People%20Management&labels[]=[Type]%20Bug&title={title}&assignee=sabeechen&body={body}"
|
||||
GITHUB_BUG_TEMPLATE = """
|
||||
###### Description:
|
||||
```
|
||||
If you have anything else that could help explain what happened, click "Markdown" above and write it here.
|
||||
```
|
||||
|
||||
Addon version: `{version}`
|
||||
Home Assistant Version: `{ha_version}`
|
||||
Supervisor Version: `{super_version}`
|
||||
Supervisor Channel: `{supervisor_channel}`
|
||||
Hassos Version: `{hassos_version}`
|
||||
Docker Version: `{docker_version}`
|
||||
Architecture: `{arch}`
|
||||
Machine: `{machine}`
|
||||
Date: `{time}`
|
||||
Timezone: `{timezone}`
|
||||
Failure Time: `{failure_time}`
|
||||
Last Good Sync: `{sync_last_start}`
|
||||
Next Sync: `{next_sync}`
|
||||
Next Backup: `{next_backup}`
|
||||
Next Cache Warm: `{next_cache_warm}`
|
||||
Time Offset: `{time_offset}`
|
||||
###### Exception:
|
||||
```
|
||||
{error}
|
||||
```
|
||||
Backups:
|
||||
```
|
||||
{backups}
|
||||
```
|
||||
###### Config:
|
||||
```
|
||||
{config}
|
||||
```
|
||||
###### Addon Logs:
|
||||
```
|
||||
{addon_logs}
|
||||
```
|
||||
###### Supervisor Logs:
|
||||
```
|
||||
{super_logs}
|
||||
```
|
||||
###### Home Assistant Core Logs:
|
||||
```
|
||||
{core_logs}
|
||||
```
|
||||
"""
|
||||
|
||||
FOLDERS = [
|
||||
{
|
||||
'slug': "homeassistant",
|
||||
'id': "folder_homeassistant",
|
||||
'name': "Home Assistant Configuration",
|
||||
'description': 'Backup the files and folders from your Home Assistant config directory, eg configuration.yaml'
|
||||
},
|
||||
{
|
||||
'slug': "media",
|
||||
'id': "folder_media",
|
||||
'name': "Media",
|
||||
'description': 'Backup your "/media" directory.'
|
||||
},
|
||||
{
|
||||
'slug': "ssl",
|
||||
'id': "folder_ssl",
|
||||
'name': "SSL",
|
||||
'description': 'Backup your "/ssl" directory, where your certfile and keyfile are typically stored.'
|
||||
},
|
||||
{
|
||||
'slug': "share",
|
||||
'id': "folder_share",
|
||||
'name': "Share",
|
||||
'description': 'Backup your "/share" directory.'
|
||||
},
|
||||
{
|
||||
'slug': "addons/local",
|
||||
'id': "folder_addons",
|
||||
'name': "Local Addons",
|
||||
'description': 'Backup your local addons directory. This directory will be empty unless you use it for add-on development.'
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
# flake8: noqa
|
||||
from .exchanger import Exchanger
|
||||
from .creds import Creds, KEY_TOKEN_EXPIRY, KEY_ACCESS_TOKEN, KEY_CLIENT_ID, KEY_CLIENT_SECRET
|
||||
from .driverequester import DriveRequester
|
||||
MANUAL_CODE_REDIRECT_URI: str = "urn:ietf:wg:oauth:2.0:oob"
|
||||
@@ -0,0 +1,90 @@
|
||||
from ..exceptions import ensureKey
|
||||
from ..time import Time
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
KEY_REFRESH_TOKEN = 'refresh_token'
|
||||
KEY_CLIENT_ID = 'client_id'
|
||||
KEY_CLIENT_SECRET = 'client_secret'
|
||||
KEY_EXPIRES_IN = 'expires_in'
|
||||
KEY_TOKEN_EXPIRY = 'token_expiry'
|
||||
KEY_ACCESS_TOKEN = 'access_token'
|
||||
|
||||
|
||||
class Creds():
|
||||
def __init__(self, time: Time, id: str, expiration: datetime,
|
||||
access_token: str, refresh_token: str,
|
||||
secret: Optional[str] = None, original_expiration: datetime = None):
|
||||
self._id = id
|
||||
self.time: Time = time
|
||||
self._secret = secret
|
||||
self._access_token = access_token
|
||||
self._refresh_token = refresh_token
|
||||
self._expiration = expiration
|
||||
self._original_expiration = original_expiration
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def secret(self):
|
||||
return self._secret
|
||||
|
||||
@property
|
||||
def refresh_token(self):
|
||||
return self._refresh_token
|
||||
|
||||
@property
|
||||
def access_token(self):
|
||||
return self._access_token
|
||||
|
||||
@property
|
||||
def expiration(self):
|
||||
if self._expiration is None:
|
||||
return self.time.now()
|
||||
return self._expiration
|
||||
|
||||
@property
|
||||
def original_expiration(self) -> datetime:
|
||||
return self._original_expiration
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
return self.time.now() >= self.expiration
|
||||
|
||||
def serialize(self, include_secret=True):
|
||||
ret = {
|
||||
"client_id": self.id
|
||||
}
|
||||
if self.secret is not None and include_secret:
|
||||
ret[KEY_CLIENT_SECRET] = self.secret
|
||||
if self.refresh_token is not None:
|
||||
ret[KEY_REFRESH_TOKEN] = self.refresh_token
|
||||
if self.access_token is not None:
|
||||
ret[KEY_ACCESS_TOKEN] = self.access_token
|
||||
if self.expiration is not None:
|
||||
ret[KEY_TOKEN_EXPIRY] = self.time.asRfc3339String(self.expiration)
|
||||
return ret
|
||||
|
||||
@classmethod
|
||||
def load(cls, time: Time, data, id=None, secret=None, original_expiration=None):
|
||||
if id is None:
|
||||
id = ensureKey(KEY_CLIENT_ID, data, "credentials")
|
||||
if secret is None and KEY_CLIENT_SECRET in data:
|
||||
secret = data[KEY_CLIENT_SECRET]
|
||||
refresh = ensureKey(KEY_REFRESH_TOKEN, data, "credentials")
|
||||
access = ensureKey(KEY_ACCESS_TOKEN, data, "credentials")
|
||||
expires = None
|
||||
try:
|
||||
if KEY_TOKEN_EXPIRY in data:
|
||||
expires = time.parse(data[KEY_TOKEN_EXPIRY])
|
||||
if original_expiration is None:
|
||||
original_expiration = expires
|
||||
elif KEY_EXPIRES_IN in data:
|
||||
expires = time.now() + timedelta(seconds=int(data[KEY_EXPIRES_IN]))
|
||||
else:
|
||||
expires = time.now()
|
||||
except BaseException:
|
||||
expires = time.now()
|
||||
return Creds(time=time, id=id, access_token=access, refresh_token=refresh, secret=secret, expiration=expires, original_expiration=original_expiration)
|
||||
@@ -0,0 +1,113 @@
|
||||
from aiohttp import ClientSession, ContentTypeError, ClientConnectorError, ClientTimeout, ClientResponse
|
||||
from aiohttp.client_exceptions import ServerTimeoutError, ServerDisconnectedError, ClientOSError
|
||||
from backup.exceptions import GoogleUnexpectedError, GoogleInternalError, GoogleRateLimitError, GoogleCredentialsExpired, CredRefreshGoogleError, DriveQuotaExceeded, GoogleDrivePermissionDenied, GoogleDnsFailure, GoogleCantConnect, GoogleTimeoutError
|
||||
from backup.util import Resolver
|
||||
from backup.logger import getLogger
|
||||
from backup.config import Config, Setting
|
||||
from injector import singleton, inject
|
||||
from dns.exception import DNSException
|
||||
|
||||
RATE_LIMIT_EXCEEDED = [403]
|
||||
TOO_MANY_REQUESTS = [429]
|
||||
INTERNAL_ERROR = [500, 503]
|
||||
PERMISSION_DENIED = [401]
|
||||
REQUEST_TIMEOUT = [408]
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@singleton
|
||||
class DriveRequester():
|
||||
@inject
|
||||
def __init__(self, config: Config, session: ClientSession, resolver: Resolver):
|
||||
self.session = session
|
||||
self.resolver = resolver
|
||||
self.config = config
|
||||
|
||||
async def request(self, method, url, headers={}, json=None, data=None) -> ClientResponse:
|
||||
try:
|
||||
response = await self.session.request(method, url, headers=headers, json=json, timeout=self.buildTimeout(), data=data)
|
||||
if response.status < 400:
|
||||
return response
|
||||
await self.raiseForKnownErrors(response)
|
||||
if response.status in PERMISSION_DENIED:
|
||||
response.release()
|
||||
raise GoogleCredentialsExpired()
|
||||
elif response.status in INTERNAL_ERROR:
|
||||
response.release()
|
||||
raise GoogleInternalError()
|
||||
elif response.status in RATE_LIMIT_EXCEEDED or response.status in TOO_MANY_REQUESTS:
|
||||
response.release()
|
||||
raise GoogleRateLimitError()
|
||||
elif response.status in REQUEST_TIMEOUT:
|
||||
response.release()
|
||||
raise GoogleTimeoutError()
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except ClientConnectorError as e:
|
||||
logger.debug(
|
||||
"Ran into trouble reaching Google Drive's servers. We'll use alternate DNS servers on the next attempt.")
|
||||
self.resolver.toggle()
|
||||
if "Cannot connect to host" in str(e) or "Connection reset by peer" in str(e):
|
||||
raise GoogleCantConnect()
|
||||
if e.os_error.errno == -2:
|
||||
# -2 means dns lookup failed.
|
||||
raise GoogleDnsFailure()
|
||||
elif str(e.os_error) == "Domain name not found":
|
||||
raise GoogleDnsFailure()
|
||||
elif e.os_error.errno in [99, 111, 10061, 104]:
|
||||
# 111 means connection refused
|
||||
# Can't connect
|
||||
raise GoogleCantConnect()
|
||||
elif "Could not contact DNS serve" in str(e.os_error):
|
||||
# Wish there was a better way to identify this exception
|
||||
raise GoogleDnsFailure()
|
||||
raise
|
||||
except ClientOSError as e:
|
||||
if e.errno == 1:
|
||||
raise GoogleUnexpectedError()
|
||||
raise
|
||||
except ServerTimeoutError:
|
||||
raise GoogleTimeoutError()
|
||||
except ServerDisconnectedError:
|
||||
raise GoogleUnexpectedError()
|
||||
except DNSException:
|
||||
logger.debug(
|
||||
"Ran into trouble resolving Google Drive's servers. We'll use normal DNS servers on the next attempt.")
|
||||
self.resolver.toggle()
|
||||
raise GoogleDnsFailure()
|
||||
|
||||
def buildTimeout(self):
|
||||
return ClientTimeout(
|
||||
sock_connect=self.config.get(
|
||||
Setting.GOOGLE_DRIVE_TIMEOUT_SECONDS),
|
||||
sock_read=self.config.get(Setting.GOOGLE_DRIVE_TIMEOUT_SECONDS))
|
||||
|
||||
async def raiseForKnownErrors(self, response):
|
||||
try:
|
||||
message = await response.json()
|
||||
except ContentTypeError:
|
||||
return
|
||||
except ValueError:
|
||||
# parsing json failed, just give up
|
||||
return
|
||||
except TypeError:
|
||||
# Same
|
||||
return
|
||||
if "error" not in message:
|
||||
return
|
||||
error_obj = message["error"]
|
||||
if isinstance(error_obj, str):
|
||||
if error_obj == "expired":
|
||||
raise GoogleCredentialsExpired()
|
||||
else:
|
||||
raise CredRefreshGoogleError(error_obj)
|
||||
if "errors" not in error_obj:
|
||||
return
|
||||
for error in error_obj["errors"]:
|
||||
if "reason" not in error:
|
||||
continue
|
||||
if error["reason"] == "storageQuotaExceeded":
|
||||
raise DriveQuotaExceeded()
|
||||
elif error["reason"] in ["forbidden", "insufficientFilePermissions"]:
|
||||
raise GoogleDrivePermissionDenied()
|
||||
@@ -0,0 +1,160 @@
|
||||
import asyncio
|
||||
from aiohttp import ClientSession, ClientConnectorError, ClientTimeout
|
||||
from .creds import Creds, KEY_CLIENT_ID, KEY_CLIENT_SECRET, KEY_ACCESS_TOKEN, KEY_REFRESH_TOKEN, KEY_EXPIRES_IN
|
||||
from ..exceptions import ensureKey, GoogleCredentialsExpired, CredRefreshGoogleError, CredRefreshMyError
|
||||
from ..config import Config, Setting, VERSION
|
||||
from yarl import URL
|
||||
from ..time import Time
|
||||
from ..logger import getLogger
|
||||
from .driverequester import DriveRequester
|
||||
from datetime import timedelta
|
||||
from injector import singleton, inject
|
||||
|
||||
|
||||
SCOPE = 'https://www.googleapis.com/auth/drive.file'
|
||||
|
||||
KEY_REDIRECT_URI = 'redirect_uri'
|
||||
KEY_SCOPE = 'scope'
|
||||
KEY_RESPONSE_TYPE = 'response_type'
|
||||
KEY_INCLUDE_GRANTED_SCOPES = 'include_granted_scopes'
|
||||
KEY_ACCESS_TYPE = 'access_type'
|
||||
KEY_STATE = 'state'
|
||||
KEY_PROMPT = 'prompt'
|
||||
KEY_CODE = 'code'
|
||||
KEY_GRANT_TYPE = 'grant_type'
|
||||
KEY_VERSION = 'version'
|
||||
KEY_CLIENT = 'client'
|
||||
|
||||
CRED_OBJECT_NAME = "credential token response"
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@singleton
|
||||
class Exchanger():
|
||||
@inject
|
||||
def __init__(self,
|
||||
time: Time,
|
||||
session: ClientSession,
|
||||
config: Config,
|
||||
drive: DriveRequester,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
redirect: URL):
|
||||
self.time = time
|
||||
self.config = config
|
||||
self.session = session
|
||||
self.drive = drive
|
||||
self._client_id = client_id
|
||||
self._client_secret = client_secret
|
||||
self._redirect = redirect
|
||||
|
||||
async def getAuthorizationUrl(self, state="") -> str:
|
||||
url = URL(self.config.get(Setting.DRIVE_AUTHORIZE_URL)).with_query({
|
||||
KEY_CLIENT_ID: self._client_id,
|
||||
KEY_SCOPE: SCOPE,
|
||||
KEY_RESPONSE_TYPE: 'code',
|
||||
KEY_INCLUDE_GRANTED_SCOPES: 'true',
|
||||
KEY_ACCESS_TYPE: "offline",
|
||||
KEY_STATE: state,
|
||||
KEY_REDIRECT_URI: str(self._redirect),
|
||||
KEY_PROMPT: "consent"
|
||||
})
|
||||
return str(url)
|
||||
|
||||
async def exchange(self, code):
|
||||
data = {
|
||||
KEY_CLIENT_ID: self._client_id,
|
||||
KEY_CLIENT_SECRET: self._client_secret,
|
||||
KEY_CODE: code,
|
||||
KEY_REDIRECT_URI: str(self._redirect),
|
||||
KEY_GRANT_TYPE: 'authorization_code'
|
||||
}
|
||||
resp = None
|
||||
async with await self.drive.request("post", self.config.get(Setting.DRIVE_TOKEN_URL), data=data) as resp:
|
||||
return Creds.load(self.time, await resp.json(), id=self._client_id, secret=self._client_secret)
|
||||
|
||||
async def refresh(self, creds: Creds):
|
||||
if creds.secret is not None:
|
||||
return await self._refresh_google(creds)
|
||||
else:
|
||||
return await self._refresh_default(creds)
|
||||
|
||||
async def _refresh_google(self, creds: Creds):
|
||||
data = {
|
||||
KEY_CLIENT_ID: creds.id,
|
||||
KEY_CLIENT_SECRET: creds.secret,
|
||||
KEY_REFRESH_TOKEN: creds.refresh_token,
|
||||
KEY_GRANT_TYPE: 'refresh_token'
|
||||
}
|
||||
async with await self.drive.request("post", self.config.get(Setting.DRIVE_REFRESH_URL), data=data) as resp:
|
||||
data = await resp.json()
|
||||
return Creds(
|
||||
self.time,
|
||||
id=creds.id,
|
||||
secret=creds.secret,
|
||||
access_token=ensureKey(KEY_ACCESS_TOKEN, data, CRED_OBJECT_NAME),
|
||||
refresh_token=creds.refresh_token,
|
||||
expiration=self._get_expiration(data),
|
||||
original_expiration=creds.original_expiration)
|
||||
|
||||
async def _refresh_default(self, creds: Creds):
|
||||
data = {
|
||||
KEY_CLIENT_ID: creds.id,
|
||||
KEY_REFRESH_TOKEN: creds.refresh_token,
|
||||
}
|
||||
token_paths = self.config.getTokenServers("/drive/refresh")
|
||||
last_error = None
|
||||
for url in token_paths:
|
||||
try:
|
||||
headers = {
|
||||
'addon_version': VERSION,
|
||||
'client': self.config.clientIdentifier()
|
||||
}
|
||||
async with self.session.post(str(url), headers=headers, json=data, timeout=ClientTimeout(total=self.config.get(Setting.EXCHANGER_TIMEOUT_SECONDS))) as resp:
|
||||
if resp.status < 400:
|
||||
return Creds.load(self.time, await resp.json(), original_expiration=creds.original_expiration)
|
||||
elif resp.status == 503:
|
||||
json = {}
|
||||
try:
|
||||
json = await resp.json()
|
||||
except BaseException:
|
||||
pass
|
||||
if "error" in json:
|
||||
if "invalid_grant" in json["error"]:
|
||||
raise GoogleCredentialsExpired()
|
||||
else:
|
||||
# Record the error, but still try other hosts
|
||||
last_error = CredRefreshGoogleError(json["error"])
|
||||
else:
|
||||
last_error = CredRefreshMyError("HTTP 503 from " + url.host)
|
||||
elif resp.status == 401:
|
||||
raise GoogleCredentialsExpired()
|
||||
else:
|
||||
try:
|
||||
extra = (await resp.json())["error"]
|
||||
except BaseException:
|
||||
extra = ""
|
||||
|
||||
# this is likely due to misconfiguration
|
||||
logger.warning("Got {0}:{1} from {2}, trying alternate server(s)...".format(resp.status, extra, url.host))
|
||||
last_error = CredRefreshMyError("HTTP {} {}".format(resp.status, extra))
|
||||
except ClientConnectorError:
|
||||
logger.warning("Unable to reach " + str(url.host) + ", trying alternate server(s)...")
|
||||
last_error = "Couldn't communicate with " + url.host
|
||||
except asyncio.exceptions.TimeoutError:
|
||||
logger.warning("Timed out communicating with " + str(url.host) + ", trying alternate server(s)...")
|
||||
last_error = "Timed out communicating with " + url.host
|
||||
logger.error("Unable to refresh credentials with Google Drive")
|
||||
if isinstance(last_error, str):
|
||||
raise CredRefreshMyError(last_error)
|
||||
elif isinstance(last_error, Exception):
|
||||
raise last_error
|
||||
else:
|
||||
raise Exception("Unexpected error type: " + str(last_error))
|
||||
|
||||
def refreshCredentials(self, refresh_token):
|
||||
return Creds(self.time, id=self._client_id, expiration=None, access_token=None, refresh_token=refresh_token, secret=self._client_secret)
|
||||
|
||||
def _get_expiration(self, data):
|
||||
return self.time.now() + timedelta(seconds=int(ensureKey(KEY_EXPIRES_IN, data, CRED_OBJECT_NAME)))
|
||||
@@ -0,0 +1,2 @@
|
||||
# flake8: noqa
|
||||
from .debug_server import DebugServer
|
||||
@@ -0,0 +1,18 @@
|
||||
from backup.config import Config, Setting, Startable
|
||||
from backup.logger import getLogger
|
||||
from injector import inject, singleton
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@singleton
|
||||
class DebugServer(Startable):
|
||||
@inject
|
||||
def __init__(self, config: Config):
|
||||
self._config = config
|
||||
|
||||
async def start(self):
|
||||
if self._config.get(Setting.DEBUGGER_PORT) is not None:
|
||||
import debugpy
|
||||
port = self._config.get(Setting.DEBUGGER_PORT)
|
||||
logger.info("Starting debugger on port {}".format(port))
|
||||
debugpy.listen(("0.0.0.0", port))
|
||||
@@ -0,0 +1,216 @@
|
||||
import asyncio
|
||||
import socket
|
||||
import aioping
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
from injector import inject, singleton
|
||||
|
||||
from backup.config import Config, Setting, VERSION, _DEFAULTS, PRIVATE
|
||||
from backup.exceptions import KnownError
|
||||
from backup.util import GlobalInfo, Resolver
|
||||
from backup.time import Time
|
||||
from backup.worker import Worker
|
||||
from backup.logger import getLogger, getHistory
|
||||
from backup.ha import HaRequests, HaSource
|
||||
from backup.model import Coordinator, DestinationPrecache
|
||||
from yarl import URL
|
||||
|
||||
logger = getLogger(__name__)
|
||||
ERROR_LOG_LENGTH = 30
|
||||
|
||||
|
||||
@singleton
|
||||
class DebugWorker(Worker):
|
||||
@inject
|
||||
def __init__(self, time: Time, info: GlobalInfo, config: Config, resolver: Resolver, session: ClientSession, ha: HaRequests, coord: Coordinator, ha_source: HaSource, precache: DestinationPrecache):
|
||||
super().__init__("Debug Worker", self.doWork, time, interval=10)
|
||||
self.time = time
|
||||
self._info = info
|
||||
self.config = config
|
||||
self.ha = ha
|
||||
self.ha_source = ha_source
|
||||
self.coord = coord
|
||||
|
||||
self.last_dns_update = None
|
||||
self.dns_info = None
|
||||
|
||||
self.last_sent_error = None
|
||||
self.last_sent_error_time = None
|
||||
self._health = None
|
||||
self.resolver = resolver
|
||||
self.session = session
|
||||
self._last_server_check = None
|
||||
self._last_server_refresh = timedelta(days=1)
|
||||
self._precache = precache
|
||||
|
||||
async def doWork(self):
|
||||
if not self.last_dns_update or self.time.now() > self.last_dns_update + timedelta(hours=12):
|
||||
await self.updateDns()
|
||||
if not self._last_server_check or self.time.now() > self._last_server_check + self._last_server_refresh:
|
||||
await self.updateHealthCheck()
|
||||
if self.config.get(Setting.SEND_ERROR_REPORTS):
|
||||
try:
|
||||
await self.maybeSendErrorReport()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Once per day, query the health endpoint of the token server to see who is up.
|
||||
# This checks for broadcast messages for all users and also finds which token
|
||||
# servers are available.
|
||||
async def updateHealthCheck(self):
|
||||
headers = {
|
||||
'client': self.config.clientIdentifier(),
|
||||
'addon_version': VERSION
|
||||
}
|
||||
self._last_server_check = self.time.now()
|
||||
for host in self.config.getTokenServers():
|
||||
url = host.with_path("/health")
|
||||
try:
|
||||
async with self.session.get(url, headers=headers, timeout=ClientTimeout(total=10)) as resp:
|
||||
resp.raise_for_status()
|
||||
self._health = await resp.json()
|
||||
self._last_server_refresh = timedelta(days=1)
|
||||
return
|
||||
except: # noqa: E722
|
||||
# ignore any error and just try a different endpoint
|
||||
pass
|
||||
|
||||
# no good token host could be found, so reset it to the default and check again sooner.
|
||||
self._last_server_refresh = timedelta(minutes=1)
|
||||
|
||||
async def maybeSendErrorReport(self):
|
||||
error = self._info._last_error
|
||||
if error is not None:
|
||||
if isinstance(error, KnownError):
|
||||
error = error.code()
|
||||
else:
|
||||
error = logger.formatException(error)
|
||||
if error != self.last_sent_error:
|
||||
self.last_sent_error = error
|
||||
if error is not None:
|
||||
self.last_sent_error_time = self.time.now()
|
||||
package = await self.buildErrorReport(error)
|
||||
else:
|
||||
package = self.buildClearReport()
|
||||
logger.info("Sending error report (see settings to disable)")
|
||||
headers = {
|
||||
'client': self.config.clientIdentifier(),
|
||||
'addon_version': VERSION
|
||||
}
|
||||
url = URL(self.config.get(Setting.AUTHORIZATION_HOST)).with_path("/logerror")
|
||||
async with self.session.post(url, headers=headers, json=package):
|
||||
pass
|
||||
|
||||
async def updateDns(self):
|
||||
self.last_dns_update = self.time.now()
|
||||
try:
|
||||
# Resolve google's addresses
|
||||
self.dns_info = await self.getPingInfo()
|
||||
self._info.setDnsInfo(self.dns_info)
|
||||
except Exception as e:
|
||||
self.dns_info = logger.formatException(e)
|
||||
|
||||
async def buildErrorReport(self, error):
|
||||
config_special = {}
|
||||
for setting in Setting:
|
||||
if self.config.get(setting) != _DEFAULTS[setting]:
|
||||
if setting in PRIVATE:
|
||||
config_special[str(setting)] = "REDACTED"
|
||||
else:
|
||||
config_special[str(setting)] = self.config.get(setting)
|
||||
report = {}
|
||||
report['config'] = config_special
|
||||
report['time'] = self.formatDate(self.time.now())
|
||||
report['start_time'] = self.formatDate(self._info._start_time)
|
||||
report['addon_version'] = VERSION
|
||||
report['failure_time'] = self.formatDate(self._info._last_failure_time)
|
||||
report['failure_count'] = self._info._failures
|
||||
report['sync_last_start'] = self.formatDate(self._info._last_sync_start)
|
||||
report['sync_count'] = self._info._syncs
|
||||
report['sync_success_count'] = self._info._successes
|
||||
report['sync_last_success'] = self.formatDate(self._info._last_sync_success)
|
||||
report['upload_count'] = self._info._uploads
|
||||
report['upload_last_size'] = self._info._last_upload_size
|
||||
report['upload_last_attempt'] = self.formatDate(self._info._last_upload)
|
||||
report['next_sync'] = self.formatDate(self.coord.nextSyncAttempt())
|
||||
report['next_backup'] = self.formatDate(self.coord.nextBackupTime())
|
||||
report['next_cache_warm'] = self.formatDate(self._precache.getNextWarmDate())
|
||||
report['time_offset'] = self._time.offset.total_seconds()
|
||||
|
||||
report['debug'] = self._info.debug
|
||||
report['version'] = VERSION
|
||||
report['error'] = error
|
||||
report['client'] = self.config.clientIdentifier()
|
||||
|
||||
if self.ha_source.isInitialized() and self.ha_source.host_info and self.ha_source.super_info and self.ha_source.ha_info:
|
||||
report["super_version"] = self.ha_source.host_info.get('supervisor', "None")
|
||||
report["hassos_version"] = self.ha_source.host_info.get('hassos', "None")
|
||||
report["docker_version"] = self.ha_source.host_info.get('docker', "None")
|
||||
report["machine"] = self.ha_source.host_info.get('machine', "None")
|
||||
report["supervisor_channel"] = self.ha_source.host_info.get('channel', "None")
|
||||
report["arch"] = self.ha_source.super_info.get('arch', "None")
|
||||
report["timezone"] = self.ha_source.super_info.get('timezone', "None")
|
||||
report["ha_version"] = self.ha_source.ha_info.get('version', "None")
|
||||
else:
|
||||
report["super_version"] = "Uninitialized"
|
||||
report["arch"] = "Uninitialized"
|
||||
report["timezone"] = "Uninitialized"
|
||||
report["ha_version"] = "Uninitialized"
|
||||
report["backups"] = self.coord.buildBackupMetrics()
|
||||
return report
|
||||
|
||||
async def buildBugReportData(self, error):
|
||||
report = await self.buildErrorReport(error)
|
||||
report['addon_logs'] = "\n".join(b for a, b in list(getHistory(0, False))[-ERROR_LOG_LENGTH:])
|
||||
try:
|
||||
report['super_logs'] = "\n".join((await self.ha.getSuperLogs()).split("\n")[-ERROR_LOG_LENGTH:])
|
||||
except Exception as e:
|
||||
report['super_logs'] = logger.formatException(e)
|
||||
try:
|
||||
report['core_logs'] = "\n".join((await self.ha.getCoreLogs()).split("\n")[-ERROR_LOG_LENGTH:])
|
||||
except Exception as e:
|
||||
report['core_logs'] = logger.formatException(e)
|
||||
return report
|
||||
|
||||
def buildClearReport(self):
|
||||
duration = self.time.now() - self.last_sent_error_time
|
||||
report = {
|
||||
'duration': str(duration)
|
||||
}
|
||||
return report
|
||||
|
||||
def formatDate(self, date: datetime):
|
||||
if date is None:
|
||||
return "Never"
|
||||
else:
|
||||
return date.isoformat()
|
||||
|
||||
async def getPingInfo(self):
|
||||
who = self.config.get(Setting.DRIVE_HOST_NAME)
|
||||
ips = await self.resolve(who)
|
||||
results = {who: {}}
|
||||
tasks = {who: {}}
|
||||
for ip in ips:
|
||||
results[who][ip] = "Unknown"
|
||||
tasks[who][ip] = asyncio.create_task(aioping.ping(ip, timeout=self.config.get(Setting.PING_TIMEOUT)))
|
||||
|
||||
# ping each server
|
||||
for server in tasks.keys():
|
||||
for ip in tasks[server].keys():
|
||||
try:
|
||||
time = await tasks[server][ip]
|
||||
results[server][ip] = f"{round(time * 1000, 0)} ms"
|
||||
except Exception as e:
|
||||
results[server][ip] = str(e)
|
||||
return results
|
||||
|
||||
async def resolve(self, who: str):
|
||||
try:
|
||||
ret = [who]
|
||||
addresses = await self.resolver.resolve(who, 443, socket.AF_INET)
|
||||
for address in addresses:
|
||||
ret.append(address['host'])
|
||||
return ret
|
||||
except Exception:
|
||||
return [who]
|
||||
@@ -0,0 +1,5 @@
|
||||
# flake8: noqa
|
||||
from .driverequests import DriveRequests, RETRY_SESSION_ATTEMPTS, UPLOAD_SESSION_EXPIRATION_DURATION, URL_START_UPLOAD, OOB_CRED_CUTOFF
|
||||
from .drivesource import DriveSource, SOURCE_GOOGLE_DRIVE
|
||||
from .folderfinder import FolderFinder
|
||||
from .authcodequery import AuthCodeQuery
|
||||
@@ -0,0 +1,107 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from backup.config import Config, Setting
|
||||
from backup.time import Time
|
||||
from backup.exceptions import GoogleCredGenerateError, KnownError, LogicError, ensureKey
|
||||
from aiohttp import ClientSession
|
||||
from injector import inject
|
||||
from .driverequests import DriveRequester
|
||||
from backup.logger import getLogger
|
||||
from backup.creds import Creds
|
||||
import asyncio
|
||||
|
||||
logger = getLogger(__name__)
|
||||
SCOPE = 'https://www.googleapis.com/auth/drive.file'
|
||||
|
||||
|
||||
class AuthCodeQuery:
|
||||
@inject
|
||||
def __init__(self, config: Config, session: ClientSession, time: Time, drive: DriveRequester):
|
||||
self.session = session
|
||||
self.config = config
|
||||
self.drive = drive
|
||||
self.time = time
|
||||
self.client_id: str = None
|
||||
self.client_secret: str = None
|
||||
self.device_code: str = None
|
||||
self.verification_url: str = None
|
||||
self.user_code: str = None
|
||||
self.check_interval: timedelta = timedelta(seconds=5)
|
||||
self.expiration: datetime = time.now()
|
||||
self.last_check = time.now()
|
||||
|
||||
async def requestCredentials(self, client_id: str, client_secret: str):
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
request_data = {
|
||||
'client_id': self.client_id,
|
||||
'scope': SCOPE
|
||||
}
|
||||
resp = await self.session.post(self.config.get(Setting.DRIVE_DEVICE_CODE_URL), data=request_data, timeout=30)
|
||||
if resp.status != 200:
|
||||
raise GoogleCredGenerateError(f"Google responded with error status HTTP {resp.status}. Please verify your credentials are set up correctly.")
|
||||
data = await resp.json()
|
||||
self.device_code = str(ensureKey("device_code", data, "Google's authorization request"))
|
||||
self.verification_url = str(ensureKey("verification_url", data, "Google's authorization request"))
|
||||
self.user_code = str(ensureKey("user_code", data, "Google's authorization request"))
|
||||
self.expiration = self.time.now() + timedelta(seconds=int(ensureKey("expires_in", data, "Google's authorization request")))
|
||||
self.check_interval = timedelta(seconds=int(ensureKey("interval", data, "Google's authorization request")))
|
||||
|
||||
async def waitForPermission(self) -> Creds:
|
||||
if not self.device_code:
|
||||
raise LogicError("Please call requestCredentials() first")
|
||||
error_count = 0
|
||||
data = {
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'device_code': self.device_code,
|
||||
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code'
|
||||
}
|
||||
while self.expiration > self.time.now():
|
||||
start = self.time.now()
|
||||
resp = None
|
||||
try:
|
||||
resp = await self.session.post(self.config.get(Setting.DRIVE_TOKEN_URL), data=data, timeout=self.check_interval.total_seconds())
|
||||
try:
|
||||
reply = await resp.json()
|
||||
except Exception:
|
||||
reply = {}
|
||||
if resp.status == 403:
|
||||
if reply.get("error", "") == "slow_down":
|
||||
# google wants us to chill out, so do that
|
||||
await asyncio.sleep(self.check_interval.total_seconds())
|
||||
else:
|
||||
# Google says no
|
||||
logger.error(f"Getting credentials from Google failed with HTTP 403 and error: {reply.get('error', 'unspecified')}")
|
||||
raise GoogleCredGenerateError("Google refused the request to connect your account, either because you rejected it or they were set up incorrectly.")
|
||||
elif resp.status == 428:
|
||||
# Google says PEBKAC
|
||||
logger.info(f"Waiting for you to authenticate with Google at {self.verification_url}")
|
||||
elif resp.status / 100 != 2:
|
||||
# Mysterious error
|
||||
logger.error(f"Getting credentials from Google failed with HTTP {resp.status} and error: {reply.get('error', 'unspecified')}")
|
||||
raise GoogleCredGenerateError("Failed unexpectedly while trying to reach Google. See the add-on logs for details.")
|
||||
else:
|
||||
# got the token, return it
|
||||
return Creds.load(self.time, reply, id=self.client_id, secret=self.client_secret)
|
||||
except KnownError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error while trying to retrieve credentials from Google")
|
||||
logger.printException(e)
|
||||
|
||||
# Allowing 10 errors is arbitrary, but prevents us from just erroring out forever in the background
|
||||
error_count += 1
|
||||
if error_count > 10:
|
||||
raise GoogleCredGenerateError("Failed unexpectedly too many times while attempting to reach Google. See the logs for details.")
|
||||
finally:
|
||||
if resp is not None:
|
||||
resp.release()
|
||||
|
||||
# Make sure we never query more than google says we should
|
||||
remainder = self.check_interval - (self.time.now() - start)
|
||||
if remainder > timedelta(seconds=0):
|
||||
await asyncio.sleep(remainder.total_seconds())
|
||||
|
||||
logger.error("Getting credentials from Google expired, please try again")
|
||||
raise GoogleCredGenerateError("Credentials expired while waiting for you to authorize with Google")
|
||||
@@ -0,0 +1,382 @@
|
||||
import io
|
||||
import math
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urlencode
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, ClientResponse
|
||||
from aiohttp.client_exceptions import ClientResponseError, ServerTimeoutError
|
||||
from injector import inject, singleton
|
||||
|
||||
from ..util import AsyncHttpGetter
|
||||
from ..config import Config, Setting
|
||||
from ..exceptions import (GoogleCredentialsExpired,
|
||||
GoogleSessionError, LogicError,
|
||||
ProtocolError, ensureKey, KnownTransient, GoogleTimeoutError, GoogleUnexpectedError)
|
||||
from backup.util import Backoff
|
||||
from backup.file import JsonFileSaver
|
||||
from ..time import Time
|
||||
from ..logger import getLogger
|
||||
from backup.creds import Creds, Exchanger, DriveRequester
|
||||
from datetime import timezone
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
MIME_TYPE = "application/tar"
|
||||
FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder'
|
||||
FOLDER_NAME = 'Home Assistant Backups'
|
||||
DRIVE_VERSION = "v3"
|
||||
DRIVE_SERVICE = "drive"
|
||||
|
||||
SELECT_FIELDS = "id,name,appProperties,size,trashed,mimeType,modifiedTime,capabilities,parents,driveId"
|
||||
THUMBNAIL_MIME_TYPE = "image/png"
|
||||
QUERY_FIELDS = "nextPageToken,files(" + SELECT_FIELDS + ")"
|
||||
CREATE_FIELDS = SELECT_FIELDS
|
||||
URL_FILES = "/drive/v3/files/"
|
||||
URL_ABOUT = "/drive/v3/about"
|
||||
URL_START_UPLOAD = "/upload/drive/v3/files/?uploadType=resumable&supportsAllDrives=true"
|
||||
PAGE_SIZE = 100
|
||||
CHUNK_SIZE = 5 * 262144
|
||||
RANGE_RE = re.compile("^bytes=0-\\d+$")
|
||||
|
||||
BASE_CHUNK_SIZE = 256 * 1024 # Google's api requires uploading chunks in multiples of 256kb
|
||||
|
||||
# During upload, chunks get sized to complete upload after 10s so we can give status updates on progress.
|
||||
CHUNK_UPLOAD_TARGET_SECONDS = 10
|
||||
|
||||
# don't attempt to resume a session with than this many times consistant failures, just in case something is broken on Google's
|
||||
# end so we don't retry the same broken session forever. Because the addon eventually backs off to doing 1 attempt/hour, this will
|
||||
# cause uploads to fail and start over after about 4 days. This gets reset every time a chunk successfully uploads.
|
||||
# God be with you if your upload takes that long.
|
||||
RETRY_SESSION_ATTEMPTS = 100
|
||||
|
||||
# Google claims that an upload session becomes invalid after 7 days. I have not verified this, but probably better to call it
|
||||
# after 6 and restart the session.
|
||||
UPLOAD_SESSION_EXPIRATION_DURATION = timedelta(days=6)
|
||||
|
||||
|
||||
RATE_LIMIT_EXCEEDED = 403
|
||||
TOO_MANY_REQUESTS = 429
|
||||
|
||||
|
||||
# Defines the retry strategy for calls made to Drive
|
||||
# max # of time to retry and call to Drive
|
||||
DRIVE_MAX_RETRIES: int = 5
|
||||
# The initial backoff for drive retries.
|
||||
DRIVE_RETRY_INITIAL_SECONDS: int = 2
|
||||
# How uch longer to wait for each Drive service call (Exponential backoff)
|
||||
DRIVE_EXPONENTIAL_BACKOFF: int = 2
|
||||
|
||||
OOB_CRED_CUTOFF = datetime(2022, 3, 16, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@singleton
|
||||
class DriveRequests():
|
||||
@inject
|
||||
def __init__(self, config: Config, time: Time, drive: DriveRequester, session: ClientSession, exchanger: Exchanger):
|
||||
self.session = session
|
||||
self.config = config
|
||||
self.time = time
|
||||
self.drive = drive
|
||||
self.creds: Optional[Creds] = None
|
||||
self.exchanger: Exchanger = exchanger
|
||||
|
||||
# Between attempts to upload, we keep track of the info needed to resume a resumable upload.
|
||||
self.last_attempt_metadata = None
|
||||
self.last_attempt_location = None
|
||||
self.last_attempt_count = 0
|
||||
self.last_attempt_start_time = None
|
||||
self.tryLoadCredentials()
|
||||
|
||||
async def _getHeaders(self):
|
||||
return {
|
||||
"Authorization": "Bearer " + await self.getToken(),
|
||||
"Client-Identifier": self.config.clientIdentifier()
|
||||
}
|
||||
|
||||
@property
|
||||
def might_be_oob_creds(self):
|
||||
"""Attempts to determine if the user might be using custom creds affected by google's OOB cred deprecation"""
|
||||
if not self.isCustomCreds():
|
||||
return False
|
||||
if self.creds.original_expiration is None:
|
||||
# These creds must be old, so assume they're affected
|
||||
return True
|
||||
try:
|
||||
return self.creds.original_expiration < OOB_CRED_CUTOFF
|
||||
except: # noqa: E722
|
||||
# Regardless of why this happens, assume they need to check
|
||||
return True
|
||||
|
||||
def isCustomCreds(self):
|
||||
return self.creds is not None and self.creds.id != self.config.get(Setting.DEFAULT_DRIVE_CLIENT_ID)
|
||||
|
||||
def _getAuthHeaders(self):
|
||||
return {
|
||||
"Client-Identifier": self.config.clientIdentifier()
|
||||
}
|
||||
|
||||
def enabled(self):
|
||||
return self.creds is not None and self.config.get(Setting.ENABLE_DRIVE_UPLOAD)
|
||||
|
||||
def _enabledCheck(self):
|
||||
if not self.enabled():
|
||||
raise LogicError(
|
||||
"Attempt to use Google Drive before credentials are configured")
|
||||
|
||||
def tryLoadCredentials(self):
|
||||
path = self.config.get(Setting.CREDENTIALS_FILE_PATH)
|
||||
if JsonFileSaver.exists(path):
|
||||
try:
|
||||
self.creds = Creds.load(self.time, JsonFileSaver.read(path))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def saveCredentials(self, creds: Creds):
|
||||
path = self.config.get(Setting.CREDENTIALS_FILE_PATH)
|
||||
if not creds:
|
||||
if JsonFileSaver.exists(path):
|
||||
JsonFileSaver.delete(path)
|
||||
self.creds = None
|
||||
return
|
||||
JsonFileSaver.write(path, creds.serialize())
|
||||
self.tryLoadCredentials()
|
||||
|
||||
async def getToken(self, refresh=False):
|
||||
if self.creds and not self.creds.is_expired and not refresh:
|
||||
return self.creds.access_token
|
||||
|
||||
# refresh the credentials
|
||||
logger.debug("Requesting refreshed Google Drive credentials")
|
||||
self.creds = await self.exchanger.refresh(self.creds)
|
||||
return self.creds.access_token
|
||||
|
||||
async def refreshToken(self):
|
||||
await self.getToken(refresh=True)
|
||||
|
||||
async def get(self, id):
|
||||
q = {
|
||||
"fields": SELECT_FIELDS,
|
||||
"supportsAllDrives": "true"
|
||||
}
|
||||
async with await self.retryRequest("GET", URL_FILES + id + "/?" + urlencode(q)) as response:
|
||||
return await response.json()
|
||||
|
||||
async def download(self, id, size):
|
||||
ret = AsyncHttpGetter(self.config.get(Setting.DRIVE_URL) + URL_FILES + id + "/?alt=media&supportsAllDrives=true",
|
||||
await self._getHeaders(),
|
||||
self.session,
|
||||
size=size,
|
||||
timeoutFactory=GoogleTimeoutError.factory,
|
||||
otherErrorFactory=GoogleUnexpectedError.factory,
|
||||
timeout=ClientTimeout(
|
||||
sock_connect=self.config.get(Setting.DOWNLOAD_TIMEOUT_SECONDS),
|
||||
sock_read=self.config.get(Setting.DOWNLOAD_TIMEOUT_SECONDS)),
|
||||
time=self.time)
|
||||
return ret
|
||||
|
||||
async def query(self, query):
|
||||
# SOMEDAY: Add a test for page size, test server support is needed too for continuation tokens
|
||||
continuation = None
|
||||
while True:
|
||||
q = {
|
||||
"q": query,
|
||||
"fields": QUERY_FIELDS,
|
||||
"pageSize": self.config.get(Setting.GOOGLE_DRIVE_PAGE_SIZE),
|
||||
"supportsAllDrives": "true",
|
||||
"includeItemsFromAllDrives": "true",
|
||||
"corpora": "allDrives"
|
||||
}
|
||||
if continuation:
|
||||
q["pageToken"] = continuation
|
||||
async with await self.retryRequest("GET", URL_FILES + "?" + urlencode(q)) as response:
|
||||
data = await response.json()
|
||||
for item in data['files']:
|
||||
yield item
|
||||
if "nextPageToken" not in data or len(data['nextPageToken']) <= 0:
|
||||
break
|
||||
else:
|
||||
continuation = data['nextPageToken']
|
||||
|
||||
async def update(self, id, update_metadata):
|
||||
async with await self.retryRequest("PATCH", URL_FILES + id + "/?supportsAllDrives=true", json=update_metadata):
|
||||
pass
|
||||
|
||||
async def delete(self, id):
|
||||
async with await self.retryRequest("DELETE", URL_FILES + id + "/?supportsAllDrives=true"):
|
||||
pass
|
||||
|
||||
async def getAboutInfo(self):
|
||||
q = {"fields": 'storageQuota,user'}
|
||||
async with await self.retryRequest("GET", URL_ABOUT + "?" + urlencode(q)) as resp:
|
||||
return await resp.json()
|
||||
|
||||
async def create(self, stream, metadata, mime_type):
|
||||
# Upload logic is complicated. See https://developers.google.com/drive/api/v3/manage-uploads#resumable
|
||||
total_size = stream.size()
|
||||
location = None
|
||||
if metadata == self.last_attempt_metadata and self.last_attempt_location is not None and self.last_attempt_count < RETRY_SESSION_ATTEMPTS and self.time.now() < self.last_attempt_start_time + UPLOAD_SESSION_EXPIRATION_DURATION:
|
||||
logger.debug(
|
||||
"Attempting to resume a previously failed upload where we left off")
|
||||
self.last_attempt_count += 1
|
||||
# Attempt to resume from a partially completed upload.
|
||||
headers = {
|
||||
"Content-Length": "0",
|
||||
"Content-Range": "bytes */{0}".format(total_size)
|
||||
}
|
||||
try:
|
||||
async with await self.retryRequest("PUT", self.last_attempt_location, headers=headers, patch_url=False) as initial:
|
||||
if initial.status == 308:
|
||||
# We can resume the upload, check where it left off
|
||||
if 'Range' in initial.headers:
|
||||
position = int(initial.headers["Range"][len("bytes=0-"):])
|
||||
stream.position(position + 1)
|
||||
else:
|
||||
# No range header in the response means no bytes have been uploaded yet.
|
||||
stream.position(0)
|
||||
logger.debug("Resuming upload at byte {0} of {1}".format(
|
||||
stream.position(), total_size))
|
||||
location = self.last_attempt_location
|
||||
else:
|
||||
logger.debug("Drive returned status code {0}, so we'll have to start the upload over again.".format(
|
||||
initial.status))
|
||||
except ClientResponseError as e:
|
||||
if e.status == 410:
|
||||
# Drive doesn't recognize the resume token, so we'll just have to start over.
|
||||
logger.debug("Drive upload session wasn't recognized, restarting upload from the beginning.")
|
||||
location = None
|
||||
self.last_attempt_location = None
|
||||
self.last_attempt_metadata = None
|
||||
raise GoogleUnexpectedError()
|
||||
if e.status == 404:
|
||||
logger.error("Drive upload session wasn't recognized (http 404), restarting upload from the beginning.")
|
||||
location = None
|
||||
self.last_attempt_location = None
|
||||
self.last_attempt_metadata = None
|
||||
raise GoogleUnexpectedError()
|
||||
else:
|
||||
raise
|
||||
|
||||
if location is None:
|
||||
# There is no session resume, so start a new one.
|
||||
logger.debug("Starting a new upload session with Google Drive")
|
||||
headers = {
|
||||
"X-Upload-Content-Type": mime_type,
|
||||
"X-Upload-Content-Length": str(total_size),
|
||||
}
|
||||
async with await self.retryRequest("POST", URL_START_UPLOAD, headers=headers, json=metadata) as initial:
|
||||
# Google returns a url in the header "Location", which is where subsequent requests to upload
|
||||
# the backup's bytes should be sent. Logic below handles uploading the file bytes in chunks.
|
||||
location = ensureKey(
|
||||
'Location', initial.headers, "Google Drive's Upload headers")
|
||||
self.last_attempt_count = 0
|
||||
stream.position(0)
|
||||
|
||||
# Keep track of the location in case the upload fails and we want to resume where we left off.
|
||||
# "metadata" is a durable fingerprint that uniquely identifies a backup, so we can use it to identify a
|
||||
# resumable partial upload in future retrys.
|
||||
self.last_attempt_location = location
|
||||
self.last_attempt_metadata = metadata
|
||||
self.last_attempt_start_time = self.time.now()
|
||||
|
||||
# Always start with the minimum chunk size and work up from there in case the last attempt
|
||||
# failed due to connectivity errors or ... whatever.
|
||||
current_chunk_size = BASE_CHUNK_SIZE
|
||||
while True:
|
||||
start = stream.position()
|
||||
data = await stream.read(current_chunk_size)
|
||||
chunk_size = len(data.getbuffer())
|
||||
if chunk_size == 0:
|
||||
raise LogicError(
|
||||
"Backup file stream ended prematurely while uploading to Google Drive")
|
||||
headers = {
|
||||
"Content-Length": str(chunk_size),
|
||||
"Content-Range": "bytes {0}-{1}/{2}".format(start, start + chunk_size - 1, total_size)
|
||||
}
|
||||
startTime = self.time.now()
|
||||
logger.debug("Sending {0} bytes to Google Drive".format(current_chunk_size))
|
||||
try:
|
||||
async with await self.retryRequest("PUT", location, headers=headers, data=data, patch_url=False) as partial:
|
||||
# Base the next chunk size on how long it took to send the last chunk.
|
||||
current_chunk_size = self._getNextChunkSize(
|
||||
current_chunk_size, (self.time.now() - startTime).total_seconds())
|
||||
|
||||
# any time a chunk gets uploaded, reset the retry counter. This lets very flaky connections
|
||||
# complete eventually after enough retrying.
|
||||
self.last_attempt_count = 1
|
||||
yield float(start + chunk_size) / float(total_size)
|
||||
if partial.status == 200 or partial.status == 201:
|
||||
# Upload completed, return the object json
|
||||
self.last_attempt_location = None
|
||||
self.last_attempt_metadata = None
|
||||
yield await self.get((await partial.json())['id'])
|
||||
break
|
||||
elif partial.status == 308:
|
||||
# Upload partially complete, seek to the new requested position
|
||||
range_bytes = ensureKey(
|
||||
"Range", partial.headers, "Google Drive's upload response headers")
|
||||
if not RANGE_RE.match(range_bytes):
|
||||
raise ProtocolError(
|
||||
"Range", partial.headers, "Google Drive's upload response headers")
|
||||
position = int(partial.headers["Range"][len("bytes=0-"):])
|
||||
stream.position(position + 1)
|
||||
else:
|
||||
partial.raise_for_status()
|
||||
except ClientResponseError as e:
|
||||
if math.floor(e.status / 100) == 4:
|
||||
# clear the cached session location URI, since a 4XX error
|
||||
# always means the upload session is no good anymore (AFAIK)
|
||||
self.last_attempt_location = None
|
||||
self.last_attempt_metadata = None
|
||||
|
||||
if e.status == 404:
|
||||
raise GoogleSessionError()
|
||||
else:
|
||||
raise e
|
||||
|
||||
def _getNextChunkSize(self, last_chunk_size, last_chunk_seconds):
|
||||
max = BASE_CHUNK_SIZE * math.floor(self.config.get(Setting.MAXIMUM_UPLOAD_CHUNK_BYTES) / BASE_CHUNK_SIZE)
|
||||
if max < BASE_CHUNK_SIZE:
|
||||
max = BASE_CHUNK_SIZE
|
||||
if last_chunk_seconds <= 0:
|
||||
return max
|
||||
next_chunk = CHUNK_UPLOAD_TARGET_SECONDS * last_chunk_size / last_chunk_seconds
|
||||
if next_chunk >= max:
|
||||
return max
|
||||
if next_chunk < BASE_CHUNK_SIZE:
|
||||
return BASE_CHUNK_SIZE
|
||||
return math.floor(next_chunk / BASE_CHUNK_SIZE) * BASE_CHUNK_SIZE
|
||||
|
||||
async def createFolder(self, metadata):
|
||||
async with await self.retryRequest("POST", URL_FILES + "?supportsAllDrives=true", json=metadata) as resp:
|
||||
return await resp.json()
|
||||
|
||||
async def retryRequest(self, method, url, auth_headers: Optional[Dict[str, str]] = None, headers: Optional[Dict[str, str]] = None, json: Optional[Dict[str, Any]] = None, data: Any = None, cred_retry: bool = True, patch_url: bool = True) -> ClientResponse:
|
||||
backoff = Backoff(base=DRIVE_RETRY_INITIAL_SECONDS, attempts=DRIVE_MAX_RETRIES)
|
||||
if patch_url:
|
||||
url = self.config.get(Setting.DRIVE_URL) + url
|
||||
while True:
|
||||
headers_to_use = await self._getHeaders()
|
||||
if headers:
|
||||
headers_to_use.update(headers)
|
||||
if self.config.get(Setting.TRACE_REQUESTS):
|
||||
logger.trace("Making Google Drive request: " + url)
|
||||
try:
|
||||
data_to_use = data
|
||||
if isinstance(data_to_use, io.BytesIO):
|
||||
# This is a pretty low-down dirty hack, but it works and lets us reuse the byte stream.
|
||||
# aiohttp complains if you pass it a large byte object
|
||||
data_to_use = io.BytesIO(data_to_use.getbuffer())
|
||||
data_to_use.seek(0)
|
||||
return await self.drive.request(method, url, headers=headers_to_use, json=json, data=data_to_use)
|
||||
except GoogleCredentialsExpired:
|
||||
# Get fresh credentials, then retry right away.
|
||||
logger.debug("Google Drive credentials have expired. We'll retry with new ones.")
|
||||
await self.refreshToken()
|
||||
except KnownTransient as e:
|
||||
backoff.backoff(e)
|
||||
logger.error("{0}: we'll retry in {1} seconds".format(e.message(), backoff.peek()))
|
||||
await self.time.sleepAsync(backoff.peek())
|
||||
except ServerTimeoutError:
|
||||
raise GoogleTimeoutError()
|
||||
@@ -0,0 +1,282 @@
|
||||
from datetime import datetime
|
||||
from io import IOBase
|
||||
from asyncio import Event
|
||||
from typing import Dict
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from aiohttp.client_exceptions import ClientResponseError
|
||||
from injector import inject, singleton
|
||||
|
||||
from ..util import AsyncHttpGetter, GlobalInfo
|
||||
from ..config import Config, Setting, CreateOptions
|
||||
from ..const import SOURCE_GOOGLE_DRIVE
|
||||
from ..exceptions import (BackupFolderInaccessible,
|
||||
ExistingBackupFolderError,
|
||||
GoogleDrivePermissionDenied, LogicError)
|
||||
from ..model.backups import (PROP_NOTE, PROP_PROTECTED, PROP_RETAINED, PROP_TYPE, PROP_VERSION)
|
||||
from ..time import Time
|
||||
from .driverequests import DriveRequests
|
||||
from .folderfinder import FolderFinder
|
||||
from .thumbnail import THUMBNAIL_IMAGE
|
||||
from ..model import BackupDestination, DriveBackup, Backup
|
||||
from ..logger import getLogger
|
||||
from ..creds.creds import Creds
|
||||
from backup.const import NECESSARY_OLD_BACKUP_NAME, NECESSARY_OLD_BACKUP_PLURAL_NAME, NECESSARY_PROP_KEY_SLUG, NECESSARY_PROP_KEY_DATE, NECESSARY_PROP_KEY_NAME
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
MIME_TYPE = "application/tar"
|
||||
THUMBNAIL_MIME_TYPE = "image/png"
|
||||
FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder'
|
||||
FOLDER_NAME = 'Home Assistant Backups'
|
||||
FOLDER_CACHE_SECONDS = 30
|
||||
DRIVE_MAX_PROPERTY_LENGTH = 120
|
||||
|
||||
|
||||
@singleton
|
||||
class DriveSource(BackupDestination):
|
||||
# SOMEDAY: read backups all in one big batch request, then sort the folder and child addons from that. Would need to add test verifying the "current" backup directory is used instead of the "latest"
|
||||
@inject
|
||||
def __init__(self, config: Config, time: Time, drive_requests: DriveRequests, info: GlobalInfo, session: ClientSession, folderfinder: FolderFinder):
|
||||
super().__init__()
|
||||
self.session = session
|
||||
self.config = config
|
||||
self.drivebackend: DriveRequests = drive_requests
|
||||
self.time = time
|
||||
self.folder_finder = folderfinder
|
||||
self._info = info
|
||||
self._uploadedAtLeastOneChunk = False
|
||||
self._drive_info = None
|
||||
self._cred_trigger = Event()
|
||||
|
||||
def saveCreds(self, creds: Creds) -> None:
|
||||
logger.info("Saving new Google Drive credentials")
|
||||
self.drivebackend.saveCredentials(creds)
|
||||
self.trigger()
|
||||
self._cred_trigger.set()
|
||||
|
||||
async def debug_wait_for_credentials(self):
|
||||
await self._cred_trigger.wait()
|
||||
self._cred_trigger.clear()
|
||||
|
||||
def isCustomCreds(self):
|
||||
return self.drivebackend.isCustomCreds()
|
||||
|
||||
@property
|
||||
def might_be_oob_creds(self) -> bool:
|
||||
return self.drivebackend.might_be_oob_creds
|
||||
|
||||
def name(self) -> str:
|
||||
return SOURCE_GOOGLE_DRIVE
|
||||
|
||||
def title(self) -> str:
|
||||
return "Google Drive"
|
||||
|
||||
def maxCount(self) -> None:
|
||||
return self.config.get(Setting.MAX_BACKUPS_IN_GOOGLE_DRIVE)
|
||||
|
||||
def upload(self) -> bool:
|
||||
return self.config.get(Setting.ENABLE_DRIVE_UPLOAD)
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return self.drivebackend.enabled()
|
||||
|
||||
def needsConfiguration(self) -> bool:
|
||||
if not self.config.get(Setting.ENABLE_DRIVE_UPLOAD):
|
||||
return False
|
||||
return super().needsConfiguration()
|
||||
|
||||
def freeSpace(self):
|
||||
if self._drive_info and self._drive_info.get("storageQuota") is not None and not self.folder_finder.currentIsSharedDrive():
|
||||
info = self._drive_info.get("storageQuota")
|
||||
if 'limit' in info and 'usage' in info:
|
||||
return int(info.get("limit")) - int((info.get("usage")))
|
||||
return super().freeSpace()
|
||||
|
||||
async def create(self, options: CreateOptions) -> DriveBackup:
|
||||
raise LogicError("Backups can't be created in Drive")
|
||||
|
||||
def checkBeforeChanges(self):
|
||||
existing = self.folder_finder.getExisting()
|
||||
if existing:
|
||||
raise ExistingBackupFolderError(
|
||||
existing.get('id'), existing.get('name'))
|
||||
|
||||
def icon(self) -> str:
|
||||
return "google-drive"
|
||||
|
||||
def isWorking(self):
|
||||
return self._uploadedAtLeastOneChunk
|
||||
|
||||
def detail(self):
|
||||
if self._drive_info and 'user' in self._drive_info and 'emailAddress' in self._drive_info['user']:
|
||||
return f'{self._drive_info["user"]["emailAddress"]}'
|
||||
else:
|
||||
return super().detail()
|
||||
|
||||
async def get(self, allow_retry=True) -> Dict[str, DriveBackup]:
|
||||
parent = await self.getFolderId()
|
||||
try:
|
||||
self._drive_info = await self.drivebackend.getAboutInfo()
|
||||
except Exception as e:
|
||||
# This is just used to get the remaining space in Drive, which is a
|
||||
# nice to have. Just log the error to debug if we can't get it
|
||||
logger.debug("Unable to retrieve Google Drive storage info: " + str(e))
|
||||
backups: Dict[str, DriveBackup] = {}
|
||||
try:
|
||||
async for child in self.drivebackend.query("'{}' in parents".format(parent)):
|
||||
properties = child.get('appProperties')
|
||||
if properties and NECESSARY_PROP_KEY_DATE in properties and NECESSARY_PROP_KEY_SLUG in properties and not child['trashed']:
|
||||
backup = DriveBackup(child)
|
||||
backups[backup.slug()] = backup
|
||||
except ClientResponseError as e:
|
||||
if e.status == 404:
|
||||
# IIUC, 404 on create can only mean that the parent id isn't valid anymore.
|
||||
if not self.config.get(Setting.SPECIFY_BACKUP_FOLDER) and allow_retry:
|
||||
self.folder_finder.deCache()
|
||||
await self.folder_finder.create()
|
||||
return await self.get(False)
|
||||
raise BackupFolderInaccessible(parent)
|
||||
raise e
|
||||
except GoogleDrivePermissionDenied:
|
||||
# This should always mean we lost permission on the backup folder, but at least it still exists.
|
||||
if not self.config.get(Setting.SPECIFY_BACKUP_FOLDER) and allow_retry:
|
||||
self.folder_finder.deCache()
|
||||
await self.folder_finder.create()
|
||||
return await self.get(False)
|
||||
raise BackupFolderInaccessible(parent)
|
||||
return backups
|
||||
|
||||
async def delete(self, backup: Backup):
|
||||
item = self._validateBackup(backup)
|
||||
if item.canDeleteDirectly():
|
||||
logger.info("Deleting '{}' From Google Drive".format(item.name()))
|
||||
await self.drivebackend.delete(item.id())
|
||||
else:
|
||||
logger.info("Trashing '{}' in Google Drive".format(item.name()))
|
||||
await self.drivebackend.update(item.id(), {"trashed": True})
|
||||
backup.removeSource(self.name())
|
||||
|
||||
async def save(self, backup: Backup, source: AsyncHttpGetter) -> DriveBackup:
|
||||
retain = backup.getOptions() and backup.getOptions().retain_sources.get(self.name(), False)
|
||||
parent_id = await self.getFolderId()
|
||||
if backup.note() is not None:
|
||||
desc = backup.note()
|
||||
else:
|
||||
desc = 'A Home Assistant backup file uploaded by Home Assistant Google Drive Backup'
|
||||
file_metadata = {
|
||||
'name': str(backup.name()) + ".tar",
|
||||
'parents': [parent_id],
|
||||
'description': desc,
|
||||
'appProperties': {
|
||||
NECESSARY_PROP_KEY_SLUG: backup.slug(),
|
||||
NECESSARY_PROP_KEY_DATE: str(backup.date()),
|
||||
PROP_TYPE: str(backup.backupType()),
|
||||
PROP_VERSION: str(backup.version()),
|
||||
PROP_PROTECTED: str(backup.protected()),
|
||||
PROP_RETAINED: str(retain),
|
||||
},
|
||||
'contentHints': {
|
||||
'indexableText': 'Home Assistant hassio ' + NECESSARY_OLD_BACKUP_NAME + ' ' + NECESSARY_OLD_BACKUP_PLURAL_NAME + ' backup backups home assistant ' + desc,
|
||||
'thumbnail': {
|
||||
'image': THUMBNAIL_IMAGE,
|
||||
'mimeType': THUMBNAIL_MIME_TYPE
|
||||
}
|
||||
},
|
||||
'createdTime': self._timeToRfc3339String(backup.date()),
|
||||
'modifiedTime': self._timeToRfc3339String(backup.date())
|
||||
}
|
||||
|
||||
if backup.note() is not None:
|
||||
file_metadata['appProperties'][PROP_NOTE] = self.truncateAppProperty(PROP_NOTE, backup.note())
|
||||
file_metadata['appProperties'][NECESSARY_PROP_KEY_NAME] = self.truncateAppProperty(NECESSARY_PROP_KEY_NAME, str(backup.name()))
|
||||
|
||||
async with source:
|
||||
try:
|
||||
logger.info("Uploading '{}' to Google Drive".format(
|
||||
backup.name()))
|
||||
size = source.size()
|
||||
self._info.upload(size)
|
||||
backup.overrideStatus("Uploading {0}%", source)
|
||||
backup.setUploadSource(self.title(), source)
|
||||
async for progress in self.drivebackend.create(source, file_metadata, MIME_TYPE):
|
||||
self._uploadedAtLeastOneChunk = True
|
||||
if isinstance(progress, float):
|
||||
logger.debug("Uploading {1} {0:.2f}%".format(
|
||||
progress * 100, backup.name()))
|
||||
else:
|
||||
return DriveBackup(progress)
|
||||
raise LogicError(
|
||||
"Google Drive backup upload didn't return a completed item before exiting")
|
||||
except ClientResponseError as e:
|
||||
if e.status == 404:
|
||||
# IIUC, 404 on create can only mean that the parent id isn't valid anymore.
|
||||
raise BackupFolderInaccessible(parent_id)
|
||||
raise e
|
||||
except GoogleDrivePermissionDenied:
|
||||
# This should always mean we lost permission on the backup folder, since we could have only just
|
||||
# created the backup item on this request.
|
||||
raise BackupFolderInaccessible(parent_id)
|
||||
finally:
|
||||
backup.clearUploadSource()
|
||||
self._uploadedAtLeastOneChunk = False
|
||||
backup.clearStatus()
|
||||
|
||||
def truncateAppProperty(self, key: str, value: str):
|
||||
# Annoylingly, Drive properties can be a maximum of 124 bytes, in len(key + value) UTF8 encoded.
|
||||
# https://developers.google.com/drive/api/guides/properties
|
||||
# Is the extra indexing REALLY that expensive? Thats like some 1990's mainframe limitation.
|
||||
# Make sure we stay well under that limit
|
||||
if value is None:
|
||||
return value
|
||||
permitted = ""
|
||||
current = 0
|
||||
while current < len(value) and len(str(key + permitted + value[current]).encode('utf-8')) < DRIVE_MAX_PROPERTY_LENGTH:
|
||||
permitted += value[current]
|
||||
current += 1
|
||||
return permitted
|
||||
|
||||
async def read(self, backup: Backup) -> IOBase:
|
||||
item = self._validateBackup(backup)
|
||||
return await self.drivebackend.download(item.id(), item.size())
|
||||
|
||||
async def retain(self, backup: Backup, retain: bool) -> None:
|
||||
item = self._validateBackup(backup)
|
||||
if item.retained() == retain:
|
||||
return
|
||||
file_metadata: Dict[str, str] = {
|
||||
'appProperties': {
|
||||
PROP_RETAINED: str(retain),
|
||||
},
|
||||
}
|
||||
await self.drivebackend.update(item.id(), file_metadata)
|
||||
item.setRetained(retain)
|
||||
|
||||
async def note(self, backup, note: str) -> None:
|
||||
item = self._validateBackup(backup)
|
||||
truncated = self.truncateAppProperty(PROP_NOTE, note)
|
||||
file_metadata: Dict[str, str] = {
|
||||
'appProperties': {
|
||||
PROP_NOTE: truncated,
|
||||
},
|
||||
'description': note,
|
||||
}
|
||||
logger.debug(f"Adding a note to drive backup '{item.name()}'")
|
||||
await self.drivebackend.update(item.id(), file_metadata)
|
||||
item.setNote(truncated)
|
||||
|
||||
async def getFolderId(self):
|
||||
return await self.folder_finder.get()
|
||||
|
||||
def _validateBackup(self, backup: Backup) -> DriveBackup:
|
||||
drive_item: DriveBackup = backup.getSource(self.name())
|
||||
if not drive_item:
|
||||
raise LogicError(
|
||||
"Requested to do something with a backup from Google Drive, but the backup has no Google Drive source")
|
||||
return drive_item
|
||||
|
||||
def _timeToRfc3339String(self, time: datetime) -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
async def _get(self, id):
|
||||
return await self.drivebackend.get(id)
|
||||
@@ -0,0 +1,204 @@
|
||||
from datetime import timedelta
|
||||
from typing import Any, Dict
|
||||
from backup.file import File
|
||||
from aiohttp.client_exceptions import ClientResponseError
|
||||
from injector import inject, singleton
|
||||
|
||||
from ..config import Config, Setting
|
||||
from ..exceptions import (BackupFolderInaccessible, BackupFolderMissingError,
|
||||
GoogleDrivePermissionDenied, LogInToGoogleDriveError)
|
||||
from ..time import Time
|
||||
from .driverequests import DriveRequests
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder'
|
||||
FOLDER_NAME = 'Home Assistant Backups'
|
||||
FOLDER_CACHE_SECONDS = 60 * 31 # 31 minutes
|
||||
|
||||
|
||||
@singleton
|
||||
class FolderFinder():
|
||||
@inject
|
||||
def __init__(self, config: Config, time: Time, drive_requests: DriveRequests):
|
||||
self.config = config
|
||||
self.drivebackend: DriveRequests = drive_requests
|
||||
self.time = time
|
||||
|
||||
# The cached folder id
|
||||
self._folderId = None
|
||||
|
||||
# When the fodler id was last cached
|
||||
self._folder_queryied_last = None
|
||||
|
||||
# These get set when an existing folder is found and should cause the UI to
|
||||
# prompt for what to do about it.
|
||||
self._existing_folder = None
|
||||
self._use_existing = None
|
||||
self._folder_details = None
|
||||
|
||||
def resolveExisting(self, val):
|
||||
if self._existing_folder:
|
||||
self._use_existing = val
|
||||
else:
|
||||
self._use_existing = None
|
||||
|
||||
def _isSharedDrive(self, folder):
|
||||
driveId = folder.get("driveId", None)
|
||||
return driveId and len(driveId) > 0
|
||||
|
||||
def currentIsSharedDrive(self):
|
||||
return self._folder_details and self._isSharedDrive(self._folder_details)
|
||||
|
||||
async def get(self):
|
||||
if self._existing_folder and self._use_existing is not None:
|
||||
if self._use_existing:
|
||||
await self.save(self._existing_folder)
|
||||
else:
|
||||
await self.create()
|
||||
self._use_existing = None
|
||||
if not self._folder_queryied_last or self._folder_queryied_last + timedelta(seconds=FOLDER_CACHE_SECONDS) < self.time.now():
|
||||
try:
|
||||
self._folderId = await self._readFolderId()
|
||||
except (BackupFolderMissingError, BackupFolderInaccessible):
|
||||
if not self.config.get(Setting.SPECIFY_BACKUP_FOLDER):
|
||||
# Search for a folder, they may have created one before
|
||||
self._existing_folder = await self._search()
|
||||
if self._existing_folder:
|
||||
self._folderId = self._existing_folder.get('id')
|
||||
else:
|
||||
# Create folder, since no other folder is available
|
||||
await self.create()
|
||||
else:
|
||||
raise
|
||||
self._folder_queryied_last = self.time.now()
|
||||
return self._folderId
|
||||
|
||||
def getExisting(self):
|
||||
return self._existing_folder
|
||||
|
||||
async def save(self, folder: Any) -> str:
|
||||
if not isinstance(folder, str):
|
||||
self._folder_details = folder
|
||||
folder = folder.get('id')
|
||||
else:
|
||||
self._folder_details = None
|
||||
logger.info("Saving backup folder: " + folder)
|
||||
File.write(self.config.get(Setting.FOLDER_FILE_PATH), folder)
|
||||
self._folderId = folder
|
||||
self._folder_queryied_last = self.time.now()
|
||||
self._existing_folder = None
|
||||
|
||||
def reset(self):
|
||||
if File.exists(self.config.get(Setting.FOLDER_FILE_PATH)):
|
||||
File.delete(self.config.get(Setting.FOLDER_FILE_PATH))
|
||||
self._folderId = None
|
||||
self._folder_queryied_last = None
|
||||
self._existing_folder = None
|
||||
|
||||
def getCachedFolder(self):
|
||||
return self._folderId
|
||||
|
||||
def deCache(self):
|
||||
self._folderId = None
|
||||
self._folder_queryied_last = None
|
||||
|
||||
async def _readFolderId(self) -> str:
|
||||
# First, check if we cached the drive folder
|
||||
if not File.exists(self.config.get(Setting.FOLDER_FILE_PATH)):
|
||||
raise BackupFolderMissingError()
|
||||
else:
|
||||
folder_id: str = File.read(self.config.get(Setting.FOLDER_FILE_PATH)).strip()
|
||||
if await self._verify(folder_id):
|
||||
return folder_id
|
||||
else:
|
||||
raise BackupFolderInaccessible(folder_id)
|
||||
|
||||
async def _search(self) -> str:
|
||||
folders = []
|
||||
|
||||
try:
|
||||
async for child in self.drivebackend.query("mimeType='" + FOLDER_MIME_TYPE + "'"):
|
||||
if self._isValidFolder(child):
|
||||
folders.append(child)
|
||||
except ClientResponseError as e:
|
||||
# 404 means the folder doesn't exist (maybe it got moved?)
|
||||
if e.status == 404:
|
||||
"Make Error"
|
||||
raise LogInToGoogleDriveError()
|
||||
else:
|
||||
raise e
|
||||
|
||||
if len(folders) == 0:
|
||||
return None
|
||||
|
||||
folders.sort(key=lambda c: Time.parse(c.get("modifiedTime")))
|
||||
# Found a folder, which means we're probably using the add-on from a
|
||||
# previous (or duplicate) installation. Record and return the id but don't
|
||||
# persist it until the user chooses to do so.
|
||||
folder = folders[len(folders) - 1]
|
||||
logger.info("Found " + folder.get('name'))
|
||||
return folder
|
||||
|
||||
async def _verify(self, id):
|
||||
if self.drivebackend.isCustomCreds():
|
||||
# If the user is using custom creds and specifying the backup folder, then chances are the
|
||||
# app doesn't have permission to access the parent folder directly. Ironically, we can still
|
||||
# query for children and add/remove backups. Not a huge deal, just
|
||||
# means we can't verify the folder still exists, isn't trashed, etc. Just let it be valid
|
||||
# and handle potential errors elsewhere.
|
||||
return True
|
||||
# Query drive for the folder to make sure it still exists and we have the right permission on it.
|
||||
try:
|
||||
folder = await self.drivebackend.get(id)
|
||||
if not self._isValidFolder(folder):
|
||||
logger.info("Provided backup folder {0} is invalid".format(id))
|
||||
return False
|
||||
self._folder_details = folder
|
||||
return True
|
||||
except ClientResponseError as e:
|
||||
if e.status == 404:
|
||||
# 404 means the folder doesn't exist (maybe it got moved?) but can also mean that we
|
||||
# just don't have permission to see the folder. Often we can still upload into it, so just
|
||||
# let it pass without further verification and let other error handling (on upload) identify problems.
|
||||
return True
|
||||
else:
|
||||
raise e
|
||||
except GoogleDrivePermissionDenied:
|
||||
# Lost permission on the backup folder
|
||||
return False
|
||||
|
||||
def _isValidFolder(self, folder) -> bool:
|
||||
try:
|
||||
caps = folder.get('capabilities')
|
||||
if folder.get('trashed'):
|
||||
return False
|
||||
elif not caps['canAddChildren']:
|
||||
return False
|
||||
elif not caps['canListChildren']:
|
||||
return False
|
||||
elif not caps.get('canDeleteChildren', False) and not caps.get('canRemoveChildren', False):
|
||||
if self._isSharedDrive(folder) and caps.get("canTrashChildren", False):
|
||||
# Allow folders in shared drives if you can still trash items inside it.
|
||||
return True
|
||||
return False
|
||||
elif folder.get("mimeType") != FOLDER_MIME_TYPE:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def create(self) -> str:
|
||||
logger.info('Creating folder "{}" in "My Drive"'.format(FOLDER_NAME))
|
||||
file_metadata: Dict[str, str] = {
|
||||
'name': FOLDER_NAME,
|
||||
'mimeType': FOLDER_MIME_TYPE,
|
||||
'appProperties': {
|
||||
"backup_folder": "true",
|
||||
},
|
||||
}
|
||||
folder = await self.drivebackend.createFolder(file_metadata)
|
||||
self._folder_details = folder
|
||||
await self.save(folder)
|
||||
return folder.get('id')
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
# flake8: noqa
|
||||
from .exceptions import UnknownNetworkStorageError, InactiveNetworkStorageError, GoogleCredGenerateError, SupervisorUnexpectedError, SupervisorTimeoutError, GoogleUnexpectedError, SupervisorFileSystemError, SupervisorPermissionError, LogInToGoogleDriveError, KnownTransient, GoogleInternalError, GoogleRateLimitError, CredRefreshGoogleError, CredRefreshMyError, BackupFolderInaccessible, BackupFolderMissingError, DeleteMutlipleBackupsError, DriveQuotaExceeded, ensureKey, ExistingBackupFolderError, UserCancelledError, UploadFailed, SupervisorConnectionError, BackupPasswordKeyInvalid, BackupInProgress, SimulatedError, ProtocolError, PleaseWait, NotUploadable, NoBackup, LowSpaceError, LogicError, KnownError, InvalidConfigurationValue, HomeAssistantDeleteError, GoogleTimeoutError, GoogleSessionError, GoogleInternalError, GoogleDrivePermissionDenied, GoogleDnsFailure, GoogleCredentialsExpired, GoogleCantConnect, ExistingBackupFolderError
|
||||
+479
@@ -0,0 +1,479 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ..const import (DRIVE_FOLDER_URL_FORMAT, ERROR_BACKUP_FOLDER_INACCESSIBLE,
|
||||
ERROR_BACKUP_FOLDER_MISSING, ERROR_BAD_PASSWORD_KEY,
|
||||
ERROR_CREDS_EXPIRED, ERROR_DRIVE_FULL,
|
||||
ERROR_EXISTING_FOLDER, ERROR_GOOGLE_CONNECT, ERROR_GOOGLE_CRED_PROCESS,
|
||||
ERROR_GOOGLE_DNS, ERROR_GOOGLE_INTERNAL,
|
||||
ERROR_GOOGLE_SESSION, ERROR_GOOGLE_TIMEOUT,
|
||||
ERROR_HA_DELETE_ERROR, ERROR_INVALID_CONFIG, ERROR_LOGIC,
|
||||
ERROR_LOW_SPACE, ERROR_MULTIPLE_DELETES, ERROR_NO_BACKUP,
|
||||
ERROR_NOT_UPLOADABLE, ERROR_PLEASE_WAIT, ERROR_PROTOCOL,
|
||||
ERROR_BACKUP_IN_PROGRESS, ERROR_UPLOAD_FAILED, LOG_IN_TO_DRIVE,
|
||||
SUPERVISOR_PERMISSION, ERROR_GOOGLE_UNEXPECTED, ERROR_SUPERVISOR_TIMEOUT, ERROR_SUPERVISOR_UNEXPECTED, ERROR_SUPERVISOR_FILE_SYSTEM,
|
||||
UNKONWN_NETWORK_STORAGE, INACTIVE_NETWORK_STORAGE)
|
||||
|
||||
|
||||
def ensureKey(key, target, name):
|
||||
if key not in target:
|
||||
raise ProtocolError(key, name, target)
|
||||
return target[key]
|
||||
|
||||
|
||||
class KnownError(Exception, ABC):
|
||||
@abstractmethod
|
||||
def message(self) -> str:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def code(self) -> str:
|
||||
pass
|
||||
|
||||
def httpStatus(self) -> int:
|
||||
return 500
|
||||
|
||||
def data(self):
|
||||
return {}
|
||||
|
||||
def retrySoon(self):
|
||||
return True
|
||||
|
||||
|
||||
class KnownTransient(KnownError):
|
||||
pass
|
||||
|
||||
|
||||
class SimulatedError(KnownError):
|
||||
def __init__(self, code=None):
|
||||
self._code = code
|
||||
|
||||
def code(self):
|
||||
return self._code
|
||||
|
||||
def message(self):
|
||||
return "Gave code " + str(self._code)
|
||||
|
||||
|
||||
class LogicError(KnownError):
|
||||
def __init__(self, message=None):
|
||||
self._message = message
|
||||
|
||||
def message(self):
|
||||
return self._message
|
||||
|
||||
def code(self):
|
||||
return ERROR_LOGIC
|
||||
|
||||
|
||||
class ProtocolError(KnownError):
|
||||
def __init__(self, parameter=None, object_name=None, debug_object=None):
|
||||
self._parameter = parameter
|
||||
self._object_name = object_name
|
||||
self._debug_object = debug_object
|
||||
|
||||
def message(self):
|
||||
if self._object_name:
|
||||
return "Required key '{0}' was missing from {1}".format(self._parameter, self._object_name)
|
||||
else:
|
||||
return self._parameter
|
||||
|
||||
def code(self):
|
||||
return ERROR_PROTOCOL
|
||||
|
||||
|
||||
class BackupInProgress(KnownError):
|
||||
def message(self):
|
||||
return "A backup is already in progress"
|
||||
|
||||
def code(self):
|
||||
return ERROR_BACKUP_IN_PROGRESS
|
||||
|
||||
|
||||
class BackupPasswordKeyInvalid(KnownError):
|
||||
def message(self):
|
||||
return "Couldn't find your backup password in your secrets file. Please check your settings."
|
||||
|
||||
def code(self):
|
||||
return ERROR_BAD_PASSWORD_KEY
|
||||
|
||||
def retrySoon(self):
|
||||
return False
|
||||
|
||||
|
||||
class UploadFailed(KnownError):
|
||||
def message(self):
|
||||
return "Backup upload failed. Please check the supervisor logs for details."
|
||||
|
||||
def code(self):
|
||||
return ERROR_UPLOAD_FAILED
|
||||
|
||||
|
||||
class GoogleCredentialsExpired(KnownError):
|
||||
def message(self):
|
||||
return "Your Google Drive credentials have expired. Please reauthorize with Google Drive through the Web UI."
|
||||
|
||||
def code(self):
|
||||
return ERROR_CREDS_EXPIRED
|
||||
|
||||
def retrySoon(self):
|
||||
return False
|
||||
|
||||
|
||||
class NoBackup(KnownError):
|
||||
def message(self):
|
||||
return "The backup doesn't exist anymore"
|
||||
|
||||
def code(self):
|
||||
return ERROR_NO_BACKUP
|
||||
|
||||
|
||||
class NotUploadable(KnownError):
|
||||
def message(self):
|
||||
return "This backup can't be uploaded to Home Assistant yet"
|
||||
|
||||
def code(self):
|
||||
return ERROR_NOT_UPLOADABLE
|
||||
|
||||
|
||||
class PleaseWait(KnownError):
|
||||
def message(self):
|
||||
return "Please wait until the sync is finished."
|
||||
|
||||
def code(self):
|
||||
return ERROR_PLEASE_WAIT
|
||||
|
||||
|
||||
class InvalidConfigurationValue(KnownError):
|
||||
def __init__(self, key=None, current=None):
|
||||
self.key = key
|
||||
self.current = current
|
||||
|
||||
def message(self):
|
||||
return "'{0}' isn't a valid value for {1}".format(str(self.current), str(self.key))
|
||||
|
||||
def code(self):
|
||||
return ERROR_INVALID_CONFIG
|
||||
|
||||
|
||||
# UI Handler Done and updated
|
||||
|
||||
class DeleteMutlipleBackupsError(KnownError):
|
||||
def __init__(self, delete_sources=None):
|
||||
self.delete_sources = delete_sources
|
||||
|
||||
def message(self):
|
||||
return "The add-on has been configured to delete more than one older backups. Please confirm this by visiting the add-on's web UI or by setting the config option 'confirm_multiple_deletes'=false in your add-on configuration."
|
||||
|
||||
def code(self):
|
||||
return ERROR_MULTIPLE_DELETES
|
||||
|
||||
def data(self):
|
||||
return self.delete_sources
|
||||
|
||||
def retrySoon(self):
|
||||
return False
|
||||
|
||||
|
||||
class DriveQuotaExceeded(KnownError):
|
||||
def message(self):
|
||||
return "Google Drive is out of space"
|
||||
|
||||
def code(self):
|
||||
return ERROR_DRIVE_FULL
|
||||
|
||||
def retrySoon(self):
|
||||
return False
|
||||
|
||||
|
||||
class GoogleDnsFailure(KnownError):
|
||||
def message(self):
|
||||
return "Unable to resolve host www.googleapis.com"
|
||||
|
||||
def code(self):
|
||||
return ERROR_GOOGLE_DNS
|
||||
|
||||
|
||||
class GoogleCantConnect(KnownError):
|
||||
def message(self):
|
||||
return "Unable to connect to www.googleapis.com"
|
||||
|
||||
def code(self):
|
||||
return ERROR_GOOGLE_CONNECT
|
||||
|
||||
|
||||
class GoogleInternalError(KnownTransient):
|
||||
def message(self):
|
||||
return "Google Drive returned an internal error (HTTP: 5XX)"
|
||||
|
||||
def code(self):
|
||||
return ERROR_GOOGLE_INTERNAL
|
||||
|
||||
|
||||
class GoogleTimeoutError(KnownError):
|
||||
def message(self):
|
||||
return "Timed out while trying to reach Google Drive"
|
||||
|
||||
def code(self):
|
||||
return ERROR_GOOGLE_TIMEOUT
|
||||
|
||||
@classmethod
|
||||
def factory(cls):
|
||||
return GoogleTimeoutError()
|
||||
|
||||
|
||||
class GoogleRateLimitError(KnownTransient):
|
||||
def message(self):
|
||||
return "The addon has made too many requests to Google Drive, and will back off"
|
||||
|
||||
def code(self):
|
||||
return "google_rate_limit"
|
||||
|
||||
|
||||
class GoogleSessionError(KnownError):
|
||||
def message(self):
|
||||
return "Upload session with Google Drive expired. The upload could not complete."
|
||||
|
||||
def code(self):
|
||||
return ERROR_GOOGLE_SESSION
|
||||
|
||||
|
||||
class HomeAssistantDeleteError(KnownError):
|
||||
def message(self):
|
||||
return "Home Assistant refused to delete the backup."
|
||||
|
||||
def code(self):
|
||||
return ERROR_HA_DELETE_ERROR
|
||||
|
||||
|
||||
class ExistingBackupFolderError(KnownError):
|
||||
def __init__(self, existing_id: str = None, existing_name: str = None):
|
||||
self.existing_id = existing_id
|
||||
self.existing_name = existing_name
|
||||
|
||||
def message(self):
|
||||
return "A backup folder already exists. Please visit the add-on Web UI to select where to backup."
|
||||
|
||||
def code(self):
|
||||
return ERROR_EXISTING_FOLDER
|
||||
|
||||
def data(self):
|
||||
return {
|
||||
"existing_url#href": DRIVE_FOLDER_URL_FORMAT.format(self.existing_id),
|
||||
"existing_name": self.existing_name
|
||||
}
|
||||
|
||||
def retrySoon(self):
|
||||
return False
|
||||
|
||||
|
||||
class BackupFolderMissingError(KnownError):
|
||||
def message(self):
|
||||
return "Please visit the add-on Web UI to select where to backup."
|
||||
|
||||
def code(self):
|
||||
return ERROR_BACKUP_FOLDER_MISSING
|
||||
|
||||
def retrySoon(self):
|
||||
return False
|
||||
|
||||
|
||||
class BackupFolderInaccessible(KnownError):
|
||||
def __init__(self, existing_id: str = None):
|
||||
self.existing_id = existing_id
|
||||
|
||||
def message(self):
|
||||
return "The choosen backup folder has become inaccessible. Please visit the addon web UI to select a backup folder."
|
||||
|
||||
def data(self):
|
||||
return {
|
||||
"existing_url#href": DRIVE_FOLDER_URL_FORMAT.format(self.existing_id)
|
||||
}
|
||||
|
||||
def code(self):
|
||||
return ERROR_BACKUP_FOLDER_INACCESSIBLE
|
||||
|
||||
|
||||
class GoogleDrivePermissionDenied(KnownError):
|
||||
def message(self):
|
||||
return "Google Drive denied the request due to permissions."
|
||||
|
||||
def code(self):
|
||||
return "google_drive_permissions"
|
||||
|
||||
|
||||
class LowSpaceError(KnownError):
|
||||
def __init__(self, pct_used=None, space_remaining=None):
|
||||
self.pct_used = pct_used
|
||||
self.space_remaining = space_remaining
|
||||
|
||||
def message(self):
|
||||
return "Your backup folder is low on disk space. Backups can't be created until space is available."
|
||||
|
||||
def code(self):
|
||||
return ERROR_LOW_SPACE
|
||||
|
||||
def data(self):
|
||||
return {
|
||||
"pct_used": self.pct_used,
|
||||
"space_remaining": self.space_remaining
|
||||
}
|
||||
|
||||
|
||||
class SupervisorConnectionError(KnownError):
|
||||
def message(self):
|
||||
return "The addon couldn't connect to the supervisor. Backups can't continue until the supervisor is responding."
|
||||
|
||||
def code(self):
|
||||
return "supervisor_connection"
|
||||
|
||||
|
||||
class UserCancelledError(KnownError):
|
||||
def message(self):
|
||||
return "Sync was cancelled by you"
|
||||
|
||||
def code(self):
|
||||
return "cancelled"
|
||||
|
||||
def retrySoon(self):
|
||||
return False
|
||||
|
||||
|
||||
class CredRefreshGoogleError(KnownError):
|
||||
def __init__(self, from_google=None):
|
||||
self.from_google = from_google
|
||||
|
||||
def message(self):
|
||||
return "Couldn't refresh your credentials with Google because: '{}'".format(self.from_google)
|
||||
|
||||
def code(self):
|
||||
return "token_refresh_google_error"
|
||||
|
||||
def data(self):
|
||||
return {
|
||||
"from_google": self.from_google
|
||||
}
|
||||
|
||||
|
||||
class CredRefreshMyError(KnownError):
|
||||
def __init__(self, reason: str = None):
|
||||
self.reason = reason
|
||||
|
||||
def message(self):
|
||||
return "Couldn't refresh Google Drive credentials because: {}".format(self.reason)
|
||||
|
||||
def code(self):
|
||||
return "token_refresh_my_error"
|
||||
|
||||
def data(self):
|
||||
return {
|
||||
"reason": self.reason
|
||||
}
|
||||
|
||||
|
||||
class LogInToGoogleDriveError(KnownError):
|
||||
def message(self):
|
||||
return "Please visit drive.google.com to activate your Google Drive account."
|
||||
|
||||
def code(self):
|
||||
return LOG_IN_TO_DRIVE
|
||||
|
||||
def retrySoon(self):
|
||||
return False
|
||||
|
||||
|
||||
class SupervisorPermissionError(KnownError):
|
||||
def message(self):
|
||||
return "The supervisor is rejecting requests from the addon. Please visit the web-UI for guidance"
|
||||
|
||||
def code(self):
|
||||
return SUPERVISOR_PERMISSION
|
||||
|
||||
def retrySoon(self):
|
||||
return True
|
||||
|
||||
|
||||
class GoogleUnexpectedError(KnownError):
|
||||
def message(self):
|
||||
return "Google gave an unexpected response"
|
||||
|
||||
def code(self):
|
||||
return ERROR_GOOGLE_UNEXPECTED
|
||||
|
||||
@classmethod
|
||||
def factory(cls):
|
||||
return GoogleUnexpectedError()
|
||||
|
||||
|
||||
class SupervisorTimeoutError(KnownError):
|
||||
def message(self):
|
||||
return "A request to the supervisor timed out"
|
||||
|
||||
def code(self):
|
||||
return ERROR_SUPERVISOR_TIMEOUT
|
||||
|
||||
@classmethod
|
||||
def factory(cls):
|
||||
return SupervisorTimeoutError()
|
||||
|
||||
|
||||
class SupervisorUnexpectedError(KnownError):
|
||||
def message(self):
|
||||
return "The supervisor gave an unexpected response"
|
||||
|
||||
def code(self):
|
||||
return ERROR_SUPERVISOR_UNEXPECTED
|
||||
|
||||
@classmethod
|
||||
def factory(cls):
|
||||
return SupervisorUnexpectedError()
|
||||
|
||||
|
||||
class SupervisorFileSystemError(KnownError):
|
||||
def message(self):
|
||||
return "The host file system is read-only. Please restart Home Assistant and verify you have enough free space."
|
||||
|
||||
def code(self):
|
||||
return ERROR_SUPERVISOR_FILE_SYSTEM
|
||||
|
||||
|
||||
class GoogleCredGenerateError(KnownError):
|
||||
def __init__(self, message):
|
||||
self._msg = message
|
||||
|
||||
def message(self):
|
||||
return self._msg
|
||||
|
||||
def code(self):
|
||||
return ERROR_GOOGLE_CRED_PROCESS
|
||||
|
||||
|
||||
class UnknownNetworkStorageError(KnownError):
|
||||
def __init__(self, name: str="Unkown"):
|
||||
self.name = name
|
||||
|
||||
def message(self):
|
||||
return f"The network storage '{self.name}' isn't recognized. Please visit the add-on web UI to select different storage or use the local disk."
|
||||
|
||||
def code(self):
|
||||
return UNKONWN_NETWORK_STORAGE
|
||||
|
||||
def data(self):
|
||||
return {
|
||||
"storage_name": self.name
|
||||
}
|
||||
|
||||
|
||||
class InactiveNetworkStorageError(KnownError):
|
||||
def __init__(self, name: str="Unkown"):
|
||||
self.name = name
|
||||
|
||||
def message(self):
|
||||
return f"The network storage '{self.name}' isn't ready. The network share must be available before it can be used for a backup."
|
||||
|
||||
def code(self):
|
||||
return INACTIVE_NETWORK_STORAGE
|
||||
|
||||
def data(self):
|
||||
return {
|
||||
"storage_name": self.name
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
from .jsonfilesaver import JsonFileSaver
|
||||
from .file import File
|
||||
@@ -0,0 +1,76 @@
|
||||
import os
|
||||
from backup.logger import getLogger
|
||||
from os.path import exists
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class File:
|
||||
"""
|
||||
The envrionment Home Assistant runs in is notorious for disk-related failures, often from running completely out of space and SD card corruption.
|
||||
Both of these can leave the addon in a state where the files it need to run are either corrupted or empty. This class attempts to mitigate that
|
||||
by writing all config files twice, first to a backup file and then to the "real" file path. Then when reading it will check both locations to try
|
||||
and find a copy of the file that isn't corrupted or deleted.
|
||||
This avoids a number of common failures, namely:
|
||||
- A power failure while writing a file can leave it empty or malformed.
|
||||
- Overwriting a file while the disk is full can truncateit without writing the new data
|
||||
- HD corruption cna make a file malformed, but its less likely to affect both files.
|
||||
"""
|
||||
@classmethod
|
||||
def _read(cls, path):
|
||||
with open(path, "r") as f:
|
||||
return f.read()
|
||||
|
||||
@classmethod
|
||||
def read(cls, path):
|
||||
try:
|
||||
data = File._read(path)
|
||||
if len(data) == 0:
|
||||
logger.error(f"The configuration file {path} had an invalid format. This could be caused by hard drive corruption or an unstable power event. We'll attempt to load from a backup file instead.")
|
||||
backup = File._backup_path(path)
|
||||
if not exists(backup):
|
||||
logger.error("Unable to locate a backup path")
|
||||
raise
|
||||
return File._read(backup)
|
||||
else:
|
||||
return data
|
||||
except FileNotFoundError:
|
||||
logger.error(f"The configuration file {path} was not found. This could be caused by hard drive corruption or an unstable power event. We'll attempt to load from a backup file instead.")
|
||||
backup = File._backup_path(path)
|
||||
if not exists(backup):
|
||||
logger.error("Unable to locate a backup path")
|
||||
raise
|
||||
return File._read(backup)
|
||||
|
||||
@classmethod
|
||||
def _write(cls, path, data):
|
||||
with open(path, "w") as f:
|
||||
f.write(data)
|
||||
|
||||
@classmethod
|
||||
def write(cls, path, data):
|
||||
# Crete the backup (recovery) file first. This ensures its present if the subsequent write is corrupted.
|
||||
File._write(File._backup_path(path), data)
|
||||
File._write(path, data)
|
||||
|
||||
@classmethod
|
||||
def exists(cls, path):
|
||||
if exists(path):
|
||||
return True
|
||||
return exists(File._backup_path(path))
|
||||
|
||||
@classmethod
|
||||
def delete(sels, path):
|
||||
if exists(File._backup_path(path)):
|
||||
os.remove(File._backup_path(path))
|
||||
if exists(path):
|
||||
os.remove(path)
|
||||
|
||||
@classmethod
|
||||
def _backup_path(cls, path):
|
||||
return path + ".backup"
|
||||
|
||||
@classmethod
|
||||
def touch(cls, file):
|
||||
with open(file, "w"):
|
||||
pass
|
||||
@@ -0,0 +1,71 @@
|
||||
import json
|
||||
import os
|
||||
from backup.logger import getLogger
|
||||
from os.path import exists
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class JsonFileSaver:
|
||||
"""
|
||||
The envrionment Home Assistant runs in is notorious for disk-related failures, often from running completely out of space and SD card corruption.
|
||||
Both of these can leave the addon in a state where the files it need to run are either corrupted or empty. This class attempts to mitigate that
|
||||
by writing all config files twice, first to a backup file and then to the "real" file path. Then when reading it will check both locations to try
|
||||
and find a copy of the file that isn't corrupted or deleted.
|
||||
This avoids a number of common failures, namely:
|
||||
- A power failure while writing a file can leave it empty or malformed.
|
||||
- Overwriting a file while the disk is full can truncateit without writing the new data
|
||||
- HD corruption cna make a file malformed, but its less likely to affect both files.
|
||||
"""
|
||||
@classmethod
|
||||
def _read(cls, path):
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
@classmethod
|
||||
def read(cls, path):
|
||||
try:
|
||||
return JsonFileSaver._read(path)
|
||||
except json.decoder.JSONDecodeError:
|
||||
logger.error(f"The configuration file {path} had an invalid format. This could be caused by hard drive corruption or an unstable power event. We'll attempt to load from a backup file instead.")
|
||||
backup = JsonFileSaver._backup_path(path)
|
||||
if not exists(backup):
|
||||
logger.error("Unable to locate a backup path")
|
||||
raise
|
||||
return JsonFileSaver._read(backup)
|
||||
except FileNotFoundError:
|
||||
logger.error(f"The configuration file {path} was not found. This could be caused by hard drive corruption or an unstable power event. We'll attempt to load from a backup file instead.")
|
||||
backup = JsonFileSaver._backup_path(path)
|
||||
if not exists(backup):
|
||||
logger.error("Unable to locate a backup path")
|
||||
raise
|
||||
return JsonFileSaver._read(backup)
|
||||
|
||||
@classmethod
|
||||
def _write(cls, path, data):
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
|
||||
@classmethod
|
||||
def write(cls, path, data):
|
||||
# Crete the backup (rcovery) file first. This ensures its present if the subsequent write is corrupted.
|
||||
JsonFileSaver._write(JsonFileSaver._backup_path(path), data)
|
||||
JsonFileSaver._write(path, data)
|
||||
|
||||
@classmethod
|
||||
def exists(cls, path):
|
||||
if exists(path):
|
||||
return True
|
||||
return exists(JsonFileSaver._backup_path(path))
|
||||
|
||||
@classmethod
|
||||
def delete(sels, path):
|
||||
if exists(JsonFileSaver._backup_path(path)):
|
||||
os.remove(JsonFileSaver._backup_path(path))
|
||||
if exists(path):
|
||||
os.remove(path)
|
||||
|
||||
@classmethod
|
||||
def _backup_path(cls, path):
|
||||
return path + ".backup"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# flake8: noqa
|
||||
from .hasource import HaSource, HABackup, PendingBackup, SOURCE_HA
|
||||
from .haupdater import HaUpdater
|
||||
from .harequests import HaRequests, EVENT_BACKUP_END, EVENT_BACKUP_START, VERSION_BACKUP_PATH
|
||||
from .backupname import BackupName, BACKUP_NAME_KEYS
|
||||
from .password import Password
|
||||
from .addon_stopper import AddonStopper
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
from backup.config import Config, Setting
|
||||
from backup.file import JsonFileSaver
|
||||
from backup.worker import Worker
|
||||
from backup.exceptions import SupervisorFileSystemError
|
||||
from .harequests import HaRequests
|
||||
from injector import inject, singleton
|
||||
from backup.time import Time
|
||||
from backup.logger import getLogger
|
||||
from datetime import timedelta
|
||||
from asyncio import Lock
|
||||
|
||||
LOGGER = getLogger(__name__)
|
||||
CHECK_DURATION = timedelta(seconds=60)
|
||||
ATTR_STATE = "state"
|
||||
ATTR_WATCHDOG = "watchdog"
|
||||
ATTR_NAME = "name"
|
||||
STATE_STOPPED = "stopped"
|
||||
STATE_STARTED = "started"
|
||||
|
||||
STATES_STOPPED = ["stopped", "unknown", "error"]
|
||||
|
||||
|
||||
@singleton
|
||||
class AddonStopper(Worker):
|
||||
@inject
|
||||
def __init__(self, config: Config, requests: HaRequests, time: Time):
|
||||
super().__init__("StartandStopTimer", self.check, time, 10)
|
||||
self.requests = requests
|
||||
self.config = config
|
||||
self.time = time
|
||||
self.must_start = set()
|
||||
self.must_enable_watchdog = set()
|
||||
self.stop_start_check_time = time.now()
|
||||
self._backing_up = False
|
||||
self.allow_run = False
|
||||
self.lock = Lock()
|
||||
|
||||
async def start(self, schedule=True):
|
||||
if schedule:
|
||||
await super().start()
|
||||
path = self.config.get(Setting.STOP_ADDON_STATE_PATH)
|
||||
if JsonFileSaver.exists(path):
|
||||
data = JsonFileSaver.read(path)
|
||||
self.must_enable_watchdog = set(data.get("watchdog", []))
|
||||
self.must_start = set(data.get("start", []))
|
||||
|
||||
def allowRun(self):
|
||||
if not self.allow_run:
|
||||
for slug in self.config.get(Setting.STOP_ADDONS).split(','):
|
||||
if len(slug) == 0:
|
||||
continue
|
||||
self.must_start.add(slug)
|
||||
self.allow_run = True
|
||||
|
||||
def isBackingUp(self, backingUp):
|
||||
self._backing_up = backingUp
|
||||
|
||||
async def stopAddons(self, self_slug):
|
||||
async with self.lock:
|
||||
self._backing_up = True
|
||||
for slug in self.config.get(Setting.STOP_ADDONS).split(','):
|
||||
if slug == self_slug or len(slug) == 0:
|
||||
# Don't ask the supervisor to stop yourself. That would be BAD.
|
||||
continue
|
||||
try:
|
||||
info = await self.requests.getAddonInfo(slug)
|
||||
if info.get(ATTR_STATE, None) == STATE_STARTED:
|
||||
if info.get(ATTR_WATCHDOG, False):
|
||||
try:
|
||||
LOGGER.info("Temporarily disabling watchdog for addon '%s'", info.get(ATTR_NAME, slug))
|
||||
await self.requests.updateAddonOptions(slug, {ATTR_WATCHDOG: False})
|
||||
self.must_enable_watchdog.add(slug)
|
||||
except Exception as e:
|
||||
LOGGER.error("Unable to disable watchdog for addon {0}".format(info.get(ATTR_NAME, slug)))
|
||||
LOGGER.printException(e)
|
||||
try:
|
||||
LOGGER.info("Stopping addon '%s'", info.get(ATTR_NAME, slug))
|
||||
await self.requests.stopAddon(slug)
|
||||
self.must_start.add(slug)
|
||||
except Exception as e:
|
||||
LOGGER.error("Unable to stop addon '{0}'".format(info.get(ATTR_NAME, slug)))
|
||||
LOGGER.printException(e)
|
||||
except Exception as e:
|
||||
LOGGER.error("Unable to lookup info for addon '{0}', please check your configuration".format(slug))
|
||||
LOGGER.printException(e)
|
||||
self._save()
|
||||
|
||||
async def startAddons(self):
|
||||
self._backing_up = False
|
||||
self.stop_start_check_time = self.time.now() + CHECK_DURATION
|
||||
await self.check()
|
||||
|
||||
async def check(self):
|
||||
async with self.lock:
|
||||
if self._backing_up:
|
||||
return
|
||||
if not self.allow_run:
|
||||
return
|
||||
changes = False
|
||||
if len(self.must_start) > 0:
|
||||
for slug in list(self.must_start):
|
||||
try:
|
||||
info = await self.requests.getAddonInfo(slug)
|
||||
state = info.get(ATTR_STATE, None)
|
||||
if info.get(ATTR_STATE, None) in STATES_STOPPED:
|
||||
LOGGER.info("Starting addon '%s'", info.get(ATTR_NAME, slug))
|
||||
await self.requests.startAddon(slug)
|
||||
self.must_start.remove(slug)
|
||||
changes = True
|
||||
elif info.get(ATTR_STATE, None) == STATE_STARTED and self.time.now() > self.stop_start_check_time:
|
||||
# Give up on restarting it, looks like it was never stopped
|
||||
self.must_start.remove(slug)
|
||||
changes = True
|
||||
else:
|
||||
LOGGER.error(f"Addon '{info.get(ATTR_NAME, slug)} had unrecognized state {state}'. The addon will most likely be unable to automatically restart this addon.", )
|
||||
except Exception as e:
|
||||
LOGGER.error("Unable to start addon '%s'", slug)
|
||||
LOGGER.printException(e)
|
||||
self.must_start.remove(slug)
|
||||
changes = True
|
||||
|
||||
if len(self.must_enable_watchdog) > 0:
|
||||
for slug in list(self.must_enable_watchdog):
|
||||
if slug in self.must_start:
|
||||
# Wait until we're done trying to start the addon before re-enabling the watchdog, otherwise the supervisor complains
|
||||
continue
|
||||
try:
|
||||
info = await self.requests.getAddonInfo(slug)
|
||||
if not info.get(ATTR_WATCHDOG, True):
|
||||
LOGGER.info("Re-enabling watchdog for addon '%s'", info.get(ATTR_NAME, slug))
|
||||
await self.requests.updateAddonOptions(slug, {ATTR_WATCHDOG: True})
|
||||
except Exception as e:
|
||||
LOGGER.error("Unable to re-enable watchdog for addon '%s'", slug)
|
||||
LOGGER.printException(e)
|
||||
self.must_enable_watchdog.remove(slug)
|
||||
changes = True
|
||||
if changes:
|
||||
self._save()
|
||||
|
||||
def _save(self):
|
||||
try:
|
||||
path = self.config.get(Setting.STOP_ADDON_STATE_PATH)
|
||||
data = {"start": list(self.must_start), "watchdog": list(self.must_enable_watchdog)}
|
||||
JsonFileSaver.write(path, data)
|
||||
except OSError:
|
||||
raise SupervisorFileSystemError()
|
||||
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
BACKUP_NAME_KEYS = {
|
||||
"{type}": lambda backup_type, now_local, host_info: backup_type,
|
||||
"{year}": lambda backup_type, now_local, host_info: now_local.strftime("%Y"),
|
||||
"{year_short}": lambda backup_type, now_local, host_info: now_local.strftime("%y"),
|
||||
"{weekday}": lambda backup_type, now_local, host_info: now_local.strftime("%A"),
|
||||
"{weekday_short}": lambda backup_type, now_local, host_info: now_local.strftime("%a"),
|
||||
"{month}": lambda backup_type, now_local, host_info: now_local.strftime("%m"),
|
||||
"{month_long}": lambda backup_type, now_local, host_info: now_local.strftime("%B"),
|
||||
"{month_short}": lambda backup_type, now_local, host_info: now_local.strftime("%b"),
|
||||
"{ms}": lambda backup_type, now_local, host_info: now_local.strftime("%f"),
|
||||
"{day}": lambda backup_type, now_local, host_info: now_local.strftime("%d"),
|
||||
"{hr24}": lambda backup_type, now_local, host_info: now_local.strftime("%H"),
|
||||
"{hr12}": lambda backup_type, now_local, host_info: now_local.strftime("%I"),
|
||||
"{min}": lambda backup_type, now_local, host_info: now_local.strftime("%M"),
|
||||
"{sec}": lambda backup_type, now_local, host_info: now_local.strftime("%S"),
|
||||
"{ampm}": lambda backup_type, now_local, host_info: now_local.strftime("%p"),
|
||||
"{version_ha}": lambda backup_type, now_local, host_info: str(host_info.get('homeassistant', 'Unknown')),
|
||||
"{version_hassos}": lambda backup_type, now_local, host_info: str(host_info.get('hassos', 'Unknown')),
|
||||
"{version_super}": lambda backup_type, now_local, host_info: str(host_info.get('supervisor', 'Unknown')),
|
||||
"{date}": lambda backup_type, now_local, host_info: now_local.strftime("%x"),
|
||||
"{time}": lambda backup_type, now_local, host_info: now_local.strftime("%X"),
|
||||
"{datetime}": lambda backup_type, now_local, host_info: now_local.strftime("%c"),
|
||||
"{isotime}": lambda backup_type, now_local, host_info: now_local.isoformat(),
|
||||
"{hostname}": lambda backup_type, now_local, host_info: str(host_info.get('hostname', 'Unknown')),
|
||||
}
|
||||
|
||||
|
||||
class BackupName():
|
||||
def resolve(self, backup_type: str, template: str, now_local: datetime, host_info) -> str:
|
||||
for key in BACKUP_NAME_KEYS:
|
||||
template = template.replace(key, BACKUP_NAME_KEYS[key](
|
||||
backup_type, now_local, host_info))
|
||||
return template
|
||||
@@ -0,0 +1,346 @@
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
from aiohttp.client_exceptions import ClientResponseError, ClientConnectorError
|
||||
from injector import inject
|
||||
from asyncio.exceptions import TimeoutError
|
||||
|
||||
from ..util import AsyncHttpGetter
|
||||
from ..config import Config, Setting, Version
|
||||
from ..exceptions import HomeAssistantDeleteError, SupervisorConnectionError, SupervisorPermissionError, SupervisorTimeoutError, SupervisorUnexpectedError
|
||||
from ..model import HABackup
|
||||
from ..logger import getLogger
|
||||
from ..util import DataCache
|
||||
from backup.time import Time
|
||||
from backup.const import NECESSARY_OLD_BACKUP_PLURAL_NAME, NECESSARY_OLD_SUPERVISOR_URL
|
||||
from yarl import URL
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
HEADER_TOKEN = "X-Supervisor-Token"
|
||||
|
||||
NOTIFICATION_ID = "backup_broken"
|
||||
EVENT_BACKUP_START = "backup_started"
|
||||
EVENT_BACKUP_END = "backup_ended"
|
||||
|
||||
VERSION_BACKUP_PATH = Version.parse("2021.8")
|
||||
VERSION_MOUNT_INFO = Version.parse("2023.6")
|
||||
|
||||
|
||||
def supervisor_call(func):
|
||||
async def wrap_and_call(*args, **kwargs):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except ClientConnectorError:
|
||||
raise SupervisorConnectionError()
|
||||
except TimeoutError:
|
||||
raise SupervisorConnectionError()
|
||||
except ClientResponseError as e:
|
||||
if e.status == 403:
|
||||
raise SupervisorPermissionError()
|
||||
raise
|
||||
return wrap_and_call
|
||||
|
||||
|
||||
class HaRequests():
|
||||
"""
|
||||
Stores logic for interacting with the supervisor add-on API
|
||||
"""
|
||||
@inject
|
||||
def __init__(self, config: Config, session: ClientSession, time: Time, data_cache: DataCache):
|
||||
self.config: Config = config
|
||||
self.cache = {}
|
||||
self.session = session
|
||||
self._time = time
|
||||
self._data_cache = data_cache
|
||||
|
||||
# default the supervisor versio to using the "most featured" when it can't be parsed.
|
||||
self._super_version = VERSION_MOUNT_INFO
|
||||
|
||||
def getSupervisorURL(self) -> URL:
|
||||
if len(self.config.get(Setting.SUPERVISOR_URL)) > 0:
|
||||
return URL(self.config.get(Setting.SUPERVISOR_URL))
|
||||
if 'SUPERVISOR_TOKEN' in os.environ:
|
||||
return URL("http://supervisor")
|
||||
else:
|
||||
return URL(NECESSARY_OLD_SUPERVISOR_URL)
|
||||
|
||||
def _getBackupPath(self):
|
||||
if self.supportsBackupPaths():
|
||||
return "backups"
|
||||
return NECESSARY_OLD_BACKUP_PLURAL_NAME
|
||||
|
||||
def supportsBackupPaths(self):
|
||||
return not self._super_version or self._super_version >= VERSION_BACKUP_PATH
|
||||
|
||||
@property
|
||||
def supportsMountInfo(self):
|
||||
return not self._super_version or self._super_version >= VERSION_MOUNT_INFO
|
||||
|
||||
@supervisor_call
|
||||
async def createBackup(self, info):
|
||||
if 'folders' in info or 'addons' in info:
|
||||
url = self.getSupervisorURL().with_path("{0}/new/partial".format(self._getBackupPath()))
|
||||
else:
|
||||
url = self.getSupervisorURL().with_path("{0}/new/full".format(self._getBackupPath()))
|
||||
return await self._postHassioData(url, info, timeout=ClientTimeout(total=self.config.get(Setting.PENDING_BACKUP_TIMEOUT_SECONDS)))
|
||||
|
||||
@supervisor_call
|
||||
async def auth(self, user: str, password: str) -> None:
|
||||
await self._postHassioData(self.getSupervisorURL().with_path("auth"), {"username": user, "password": password}, headers=self._altAuthHeaders())
|
||||
|
||||
@supervisor_call
|
||||
async def upload(self, stream):
|
||||
url = self.getSupervisorURL().with_path("{0}/new/upload".format(self._getBackupPath()))
|
||||
return await self._postHassioData(url, data=stream)
|
||||
|
||||
@supervisor_call
|
||||
async def delete(self, slug) -> None:
|
||||
if slug in self.cache:
|
||||
del self.cache[slug]
|
||||
try:
|
||||
if self.supportsBackupPaths():
|
||||
delete_url = self.getSupervisorURL().with_path("{1}/{0}".format(slug, self._getBackupPath()))
|
||||
await self._sendHassioData("delete", delete_url, {})
|
||||
else:
|
||||
delete_url = self.getSupervisorURL().with_path("{1}/{0}/remove".format(slug, self._getBackupPath()))
|
||||
await self._sendHassioData("post", delete_url, {})
|
||||
except ClientResponseError as e:
|
||||
if e.status == 400:
|
||||
raise HomeAssistantDeleteError()
|
||||
raise e
|
||||
|
||||
@supervisor_call
|
||||
async def startAddon(self, slug) -> None:
|
||||
url = self.getSupervisorURL().with_path("addons/{0}/start".format(slug))
|
||||
await self._postHassioData(url, {})
|
||||
|
||||
@supervisor_call
|
||||
async def stopAddon(self, slug) -> None:
|
||||
url = self.getSupervisorURL().with_path("addons/{0}/stop".format(slug))
|
||||
await self._postHassioData(url, {})
|
||||
|
||||
@supervisor_call
|
||||
async def backup(self, slug):
|
||||
if slug in self.cache:
|
||||
info = self.cache[slug]
|
||||
else:
|
||||
info = await self._getHassioData(self.getSupervisorURL().with_path("{1}/{0}/info".format(slug, self._getBackupPath())))
|
||||
self.cache[slug] = info
|
||||
return HABackup(info, self._data_cache, self.config, self.config.isRetained(slug))
|
||||
|
||||
@supervisor_call
|
||||
async def backups(self):
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path(self._getBackupPath()))
|
||||
|
||||
@supervisor_call
|
||||
async def haInfo(self):
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path("core/info"))
|
||||
|
||||
@supervisor_call
|
||||
async def selfInfo(self) -> Dict[str, Any]:
|
||||
return await self.getAddonInfo("self")
|
||||
|
||||
@supervisor_call
|
||||
async def getAddonInfo(self, addon_slug) -> Dict[str, Any]:
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path("addons/{0}/info".format(addon_slug)))
|
||||
|
||||
@supervisor_call
|
||||
async def getAddons(self) -> Dict[str, Any]:
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path("addons"))
|
||||
|
||||
@supervisor_call
|
||||
async def hassosInfo(self) -> Dict[str, Any]:
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path("hassos/info"))
|
||||
|
||||
@supervisor_call
|
||||
async def info(self) -> Dict[str, Any]:
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path("info"))
|
||||
|
||||
@supervisor_call
|
||||
async def refreshBackups(self):
|
||||
url = self.getSupervisorURL().with_path("{0}/reload".format(self._getBackupPath()))
|
||||
return await self._postHassioData(url)
|
||||
|
||||
@supervisor_call
|
||||
async def supervisorInfo(self):
|
||||
url = self.getSupervisorURL().with_path("supervisor/info")
|
||||
info = await self._getHassioData(url)
|
||||
|
||||
# parse the supervisor version
|
||||
if 'version' in info:
|
||||
self._super_version = Version.parse(info['version'])
|
||||
return info
|
||||
|
||||
@supervisor_call
|
||||
async def mountInfo(self):
|
||||
if self.supportsMountInfo:
|
||||
url = self.getSupervisorURL().with_path("mounts")
|
||||
info = await self._getHassioData(url)
|
||||
return info
|
||||
else:
|
||||
return {
|
||||
"default_backup_mount": None,
|
||||
"mounts": []
|
||||
}
|
||||
|
||||
@supervisor_call
|
||||
async def restore(self, slug: str, password: str = None) -> None:
|
||||
url = self.getSupervisorURL().with_path("{1}/{0}/restore/full".format(slug, self._getBackupPath()))
|
||||
if password:
|
||||
await self._postHassioData(url, {'password': password})
|
||||
else:
|
||||
await self._postHassioData(url, {})
|
||||
|
||||
@supervisor_call
|
||||
async def download(self, slug) -> AsyncHttpGetter:
|
||||
url = self.getSupervisorURL().with_path("{1}/{0}/download".format(slug, self._getBackupPath()))
|
||||
ret = AsyncHttpGetter(url,
|
||||
self._getAuthHeaders(),
|
||||
self.session,
|
||||
timeoutFactory=SupervisorTimeoutError.factory,
|
||||
otherErrorFactory=SupervisorUnexpectedError.factory,
|
||||
timeout=ClientTimeout(sock_connect=self.config.get(Setting.DOWNLOAD_TIMEOUT_SECONDS),
|
||||
sock_read=self.config.get(Setting.DOWNLOAD_TIMEOUT_SECONDS)),
|
||||
time=self._time)
|
||||
return ret
|
||||
|
||||
@supervisor_call
|
||||
async def getSuperLogs(self):
|
||||
url = self.getSupervisorURL().with_path("supervisor/logs")
|
||||
async with self.session.get(url, headers=self._getAuthHeaders()) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.text()
|
||||
|
||||
@supervisor_call
|
||||
async def getCoreLogs(self):
|
||||
url = self.getSupervisorURL().with_path("core/logs")
|
||||
async with self.session.get(url, headers=self._getAuthHeaders()) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.text()
|
||||
|
||||
async def _validateHassioReply(self, resp) -> Dict[str, Any]:
|
||||
async with resp:
|
||||
resp.raise_for_status()
|
||||
details: Dict[str, Any] = await resp.json()
|
||||
if "result" not in details or details["result"] != "ok":
|
||||
if "result" in details:
|
||||
raise Exception("Hassio said: " + details["result"])
|
||||
else:
|
||||
raise Exception(
|
||||
"Malformed response from Hassio: " + str(details))
|
||||
|
||||
if "data" not in details:
|
||||
return {}
|
||||
if self.config.get(Setting.TRACE_REQUESTS):
|
||||
logger.trace("Hassio replied: %s", details)
|
||||
return details["data"]
|
||||
|
||||
async def getAddonLogo(self, slug: str):
|
||||
url = self.getSupervisorURL().with_path("addons/{0}/icon".format(slug))
|
||||
async with self.session.get(url, headers=self._getAuthHeaders()) as resp:
|
||||
resp.raise_for_status()
|
||||
return (resp.headers['Content-Type'], await resp.read())
|
||||
|
||||
def _getToken(self):
|
||||
configured = self.config.get(Setting.SUPERVISOR_TOKEN)
|
||||
if configured and len(configured) > 0:
|
||||
return configured
|
||||
if "SUPERVISOR_TOKEN" in os.environ:
|
||||
return os.environ.get("SUPERVISOR_TOKEN")
|
||||
# Older versions of the supervisor use a different name for the token.
|
||||
return os.environ.get("HASSIO_TOKEN")
|
||||
|
||||
def _getAuthHeaders(self):
|
||||
return {
|
||||
'Authorization': 'Bearer ' + self._getToken()
|
||||
}
|
||||
|
||||
def _altAuthHeaders(self):
|
||||
return {
|
||||
HEADER_TOKEN: self._getToken()
|
||||
}
|
||||
|
||||
@supervisor_call
|
||||
async def _getHassioData(self, url: URL) -> Dict[str, Any]:
|
||||
if self.config.get(Setting.TRACE_REQUESTS):
|
||||
logger.trace("Making Hassio request: " + str(url))
|
||||
return await self._validateHassioReply(await self.session.get(url, headers=self._getAuthHeaders()))
|
||||
|
||||
async def _postHassioData(self, url: URL, json=None, file=None, data=None, timeout=None, headers=None) -> Dict[str, Any]:
|
||||
return await self._sendHassioData("post", url, json, file, data, timeout, headers)
|
||||
|
||||
@supervisor_call
|
||||
async def _sendHassioData(self, method: str, url: URL, json=None, file=None, data=None, timeout=None, headers=None) -> Dict[str, Any]:
|
||||
if headers is None:
|
||||
headers = self._getAuthHeaders()
|
||||
if self.config.get(Setting.TRACE_REQUESTS):
|
||||
logger.trace("Making Hassio request: " + str(url))
|
||||
return await self._validateHassioReply(await self.session.request(method, url, headers=headers, json=json, data=data, timeout=timeout))
|
||||
|
||||
async def _postHaData(self, path: str, data: Dict[str, Any]) -> None:
|
||||
url = self.getSupervisorURL().with_path("/core/api/" + path)
|
||||
async with self.session.post(url, headers=self._getAuthHeaders(), json=data) as resp:
|
||||
resp.raise_for_status()
|
||||
|
||||
async def sendNotification(self, title: str, message: str) -> None:
|
||||
data: Dict[str, str] = {
|
||||
"title": title,
|
||||
"message": message,
|
||||
"notification_id": NOTIFICATION_ID
|
||||
}
|
||||
await self._postHaData("services/persistent_notification/create", data)
|
||||
|
||||
async def eventBackupStart(self, name, time):
|
||||
await self._sendEvent(EVENT_BACKUP_START, {
|
||||
'backup_name': name,
|
||||
'backup_time': str(time)
|
||||
})
|
||||
|
||||
async def eventBackupEnd(self, name, time, completed):
|
||||
await self._sendEvent(EVENT_BACKUP_END, {
|
||||
'completed': completed,
|
||||
'backup_name': name,
|
||||
'backup_time': str(time)
|
||||
})
|
||||
|
||||
async def _sendEvent(self, event_name: str, data: Dict[str, str]) -> None:
|
||||
await self._postHaData("events/" + event_name, data)
|
||||
|
||||
async def dismissNotification(self) -> None:
|
||||
data: Dict[str, str] = {
|
||||
"notification_id": NOTIFICATION_ID
|
||||
}
|
||||
await self._postHaData("services/persistent_notification/dismiss", data)
|
||||
|
||||
async def updateBackupStaleSensor(self, state: bool) -> None:
|
||||
if self.config.get(Setting.CALL_BACKUP_SNAPSHOT):
|
||||
data: Dict[str, Any] = {
|
||||
"state": state,
|
||||
"attributes": {
|
||||
"friendly_name": "Snapshots Stale",
|
||||
"device_class": "problem"
|
||||
}
|
||||
}
|
||||
await self._postHaData("states/binary_sensor." + NECESSARY_OLD_BACKUP_PLURAL_NAME + "_stale", data)
|
||||
else:
|
||||
data: Dict[str, Any] = {
|
||||
"state": state,
|
||||
"attributes": {
|
||||
"friendly_name": "Backups Stale",
|
||||
"device_class": "problem"
|
||||
}
|
||||
}
|
||||
await self._postHaData("states/binary_sensor.backups_stale", data)
|
||||
|
||||
@supervisor_call
|
||||
async def updateConfig(self, config) -> None:
|
||||
return await self._postHassioData(self.getSupervisorURL().with_path("addons/self/options"), {'options': config})
|
||||
|
||||
@supervisor_call
|
||||
async def updateAddonOptions(self, slug, options):
|
||||
return await self._postHassioData(self.getSupervisorURL().with_path("addons/{0}/options".format(slug)), options)
|
||||
|
||||
async def updateEntity(self, entity, data):
|
||||
await self._postHaData("states/" + entity, data)
|
||||
@@ -0,0 +1,574 @@
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from datetime import timedelta
|
||||
from io import IOBase
|
||||
from threading import Lock, Thread
|
||||
from typing import Dict, List, Optional, Any, Union
|
||||
|
||||
from aiohttp.client_exceptions import ClientResponseError
|
||||
from injector import inject, singleton
|
||||
|
||||
from backup.util import AsyncHttpGetter, GlobalInfo, Estimator, DataCache, KEY_NOTE, KEY_LAST_SEEN, KEY_PENDING, KEY_NAME, KEY_CREATED, KEY_I_MADE_THIS, KEY_IGNORE
|
||||
from ..config import Config, Setting, CreateOptions, Startable, Version
|
||||
from ..const import SOURCE_HA
|
||||
from ..model import BackupSource, AbstractBackup, HABackup, Backup
|
||||
from ..exceptions import (LogicError, BackupInProgress, UnknownNetworkStorageError, InactiveNetworkStorageError,
|
||||
UploadFailed, ensureKey)
|
||||
from .harequests import HaRequests
|
||||
from .password import Password
|
||||
from .backupname import BackupName
|
||||
from ..time import Time
|
||||
from ..logger import getLogger, StandardLogger
|
||||
from backup.const import FOLDERS, NECESSARY_OLD_BACKUP_PLURAL_NAME
|
||||
from .addon_stopper import LOGGER, AddonStopper
|
||||
|
||||
logger: StandardLogger = getLogger(__name__)
|
||||
|
||||
class PendingBackup(AbstractBackup):
|
||||
def __init__(self, backupType, protected, options: CreateOptions, request_info, config, time):
|
||||
super().__init__(
|
||||
name=request_info['name'],
|
||||
slug="pending",
|
||||
date=options.when,
|
||||
size="pending",
|
||||
source=SOURCE_HA,
|
||||
backupType=backupType,
|
||||
version="",
|
||||
protected=protected,
|
||||
retained=False,
|
||||
uploadable=False,
|
||||
details=None,
|
||||
note=options.note,
|
||||
pending=True)
|
||||
self._config = config
|
||||
self._failed = False
|
||||
self._complete = False
|
||||
self._exception = None
|
||||
self._failed_at = None
|
||||
self.setOptions(options)
|
||||
self._request_info = request_info
|
||||
self._completed_slug = None
|
||||
self._time = time
|
||||
self._pending_subverted = False
|
||||
self._start_time = time.now()
|
||||
|
||||
def considerForPurge(self) -> bool:
|
||||
return False
|
||||
|
||||
def startTime(self):
|
||||
return self._start_time
|
||||
|
||||
def failed(self, exception, time):
|
||||
self._failed = True
|
||||
self._exception = exception
|
||||
self._failed_at = time
|
||||
|
||||
def getFailureTime(self):
|
||||
return self._failed_at
|
||||
|
||||
def complete(self, slug):
|
||||
self._complete = True
|
||||
self._completed_slug = slug
|
||||
|
||||
def setPendingUnknown(self):
|
||||
self._name = "Pending Backup"
|
||||
self._backupType = "unknown"
|
||||
self._protected = False
|
||||
self._pending_subverted = True
|
||||
self._note = None
|
||||
|
||||
def createdSlug(self):
|
||||
return self._completed_slug
|
||||
|
||||
def isComplete(self):
|
||||
return self._complete
|
||||
|
||||
def isFailed(self):
|
||||
return self._failed
|
||||
|
||||
def status(self):
|
||||
if self._complete:
|
||||
return "Created"
|
||||
if self._failed:
|
||||
return "Failed!"
|
||||
return "Pending"
|
||||
|
||||
def raiseIfNeeded(self):
|
||||
if self.isFailed():
|
||||
raise self._exception
|
||||
if self._pending_subverted:
|
||||
raise BackupInProgress()
|
||||
|
||||
def isStale(self):
|
||||
if self._pending_subverted:
|
||||
delta = timedelta(seconds=self._config.get(
|
||||
Setting.BACKUP_STALE_SECONDS))
|
||||
if self._time.now() > self.startTime() + delta:
|
||||
return True
|
||||
if not self.isFailed():
|
||||
return False
|
||||
delta = timedelta(seconds=self._config.get(
|
||||
Setting.FAILED_BACKUP_TIMEOUT_SECONDS))
|
||||
staleTime = self.getFailureTime() + delta
|
||||
return self._time.now() >= staleTime
|
||||
|
||||
def madeByTheAddon(self):
|
||||
return True
|
||||
|
||||
|
||||
@singleton
|
||||
class HaSource(BackupSource[HABackup], Startable):
|
||||
"""
|
||||
Stores logic for interacting with the supervisor add-on API
|
||||
"""
|
||||
@inject
|
||||
def __init__(self, config: Config, time: Time, ha: HaRequests, info: GlobalInfo, stopper: AddonStopper, estimator: Estimator, data_cache: DataCache):
|
||||
super().__init__()
|
||||
self.config: Config = config
|
||||
self._data_cache = data_cache
|
||||
self.backup_thread: Thread = None
|
||||
self.pending_backup_error: Optional[Exception] = None
|
||||
self.pending_backup_slug: Optional[str] = None
|
||||
self.self_info = None
|
||||
self.host_info = None
|
||||
self.ha_info = None
|
||||
self.super_info = None
|
||||
self.mount_info = {}
|
||||
self.lock: Lock = Lock()
|
||||
self.time = time
|
||||
self.harequests = ha
|
||||
self.last_slugs = set()
|
||||
self.retained = []
|
||||
self.cached_retention = {}
|
||||
self._info = info
|
||||
self.pending_options = {}
|
||||
self.stopper = stopper
|
||||
self.estimator = estimator
|
||||
self._addons = {}
|
||||
self._changes_from_last_query = False
|
||||
|
||||
# This lock should be used for _ANYTHING_ that interacts with self._pending_backup
|
||||
self._pending_backup_lock = asyncio.Lock()
|
||||
self.pending_backup: Optional[PendingBackup] = None
|
||||
self._pending_backup_task = None
|
||||
self._initialized = False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._initialized
|
||||
|
||||
async def check(self) -> bool:
|
||||
pending = self.pending_backup
|
||||
if pending and pending.isStale():
|
||||
self.trigger()
|
||||
return await super().check()
|
||||
|
||||
def icon(self) -> str:
|
||||
return "home-assistant"
|
||||
|
||||
def name(self) -> str:
|
||||
return SOURCE_HA
|
||||
|
||||
def title(self) -> str:
|
||||
return "Home Assistant"
|
||||
|
||||
def maxCount(self) -> None:
|
||||
return self.config.get(Setting.MAX_BACKUPS_IN_HA)
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return True
|
||||
|
||||
def freeSpace(self):
|
||||
return self.estimator.getBytesFree()
|
||||
|
||||
async def create(self, options: CreateOptions) -> HABackup:
|
||||
# Make sure instance info is up-to-date, for the backup name
|
||||
await self._refreshInfo()
|
||||
|
||||
# Set a default name if it was unspecified
|
||||
if options.name_template is None or len(options.name_template) == 0:
|
||||
options.name_template = self.config.get(Setting.BACKUP_NAME)
|
||||
|
||||
# Build the backup request json, get type, etc
|
||||
request, type_name, protected = self._buildBackupInfo(
|
||||
options)
|
||||
|
||||
async with self._pending_backup_lock:
|
||||
# Check if a backup is already in progress
|
||||
if self.pending_backup:
|
||||
if not self.pending_backup.isFailed() and not self.pending_backup.isComplete():
|
||||
logger.info("A backup was already in progress")
|
||||
raise BackupInProgress()
|
||||
|
||||
# try to stop addons
|
||||
await self.stopper.stopAddons(self.self_info['slug'])
|
||||
|
||||
# Create the backup palceholder object
|
||||
self.pending_backup = PendingBackup(
|
||||
type_name, protected, options, request, self.config, self.time)
|
||||
logger.info("Requesting a new backup")
|
||||
self._pending_backup_task = asyncio.create_task(self._requestAsync(
|
||||
self.pending_backup), name="Pending Backup Requester")
|
||||
await asyncio.wait({self._pending_backup_task}, timeout=self.config.get(Setting.NEW_BACKUP_TIMEOUT_SECONDS))
|
||||
self.pending_backup.raiseIfNeeded()
|
||||
|
||||
# There is not other backup in progress, so assume its been requested.
|
||||
pending = self._data_cache.backup(KEY_PENDING)
|
||||
pending[KEY_NAME] = request['name']
|
||||
pending[KEY_CREATED] = options.when.isoformat()
|
||||
pending[KEY_LAST_SEEN] = self.time.now().isoformat()
|
||||
self._data_cache.makeDirty()
|
||||
if self.pending_backup.isComplete():
|
||||
# It completed while we waited, so just query the new backup
|
||||
ret = await self.harequests.backup(self.pending_backup.createdSlug())
|
||||
if options.note is not None:
|
||||
ret.setNote(options.note)
|
||||
self.setDataCacheInfo(ret)
|
||||
self._data_cache.backup(ret.slug())[KEY_I_MADE_THIS] = True
|
||||
return ret
|
||||
else:
|
||||
return self.pending_backup
|
||||
|
||||
def _isHttp400(self, e):
|
||||
if isinstance(e, ClientResponseError):
|
||||
return e.status == 400
|
||||
return False
|
||||
|
||||
async def start(self):
|
||||
try:
|
||||
await self.init()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def stop(self):
|
||||
if self._pending_backup_task:
|
||||
self._pending_backup_task.cancel()
|
||||
await asyncio.wait([self._pending_backup_task])
|
||||
|
||||
@property
|
||||
def needsSpaceCheck(self):
|
||||
if not self.harequests.supportsMountInfo:
|
||||
return True
|
||||
if self.config.get(Setting.BACKUP_STORAGE) == 'local-disk':
|
||||
return True
|
||||
if self.mount_info.get("default_backup_mount") is None and len(self.config.get(Setting.BACKUP_STORAGE)) == 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def query_had_changes(self):
|
||||
return self._changes_from_last_query
|
||||
|
||||
async def get(self) -> Dict[str, HABackup]:
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
else:
|
||||
# Always ensure the supervisor version is fresh before makign any other requests
|
||||
self.super_info = await self.harequests.supervisorInfo()
|
||||
slugs = set()
|
||||
retained = []
|
||||
backups: Dict[str, HABackup] = {}
|
||||
query = await self.harequests.backups()
|
||||
|
||||
# Different supervisor version use different names for the list of backups
|
||||
backup_list = []
|
||||
if NECESSARY_OLD_BACKUP_PLURAL_NAME in query:
|
||||
backup_list = query[NECESSARY_OLD_BACKUP_PLURAL_NAME]
|
||||
if 'backups' in query:
|
||||
backup_list = query['backups']
|
||||
|
||||
for backup in backup_list:
|
||||
slug = backup['slug']
|
||||
slugs.add(slug)
|
||||
item = await self.harequests.backup(slug)
|
||||
if slug in self.pending_options:
|
||||
item.setOptions(self.pending_options[slug])
|
||||
backups[slug] = item
|
||||
if item.retained():
|
||||
retained.append(item.slug())
|
||||
self.setDataCacheInfo(item)
|
||||
if self.pending_backup:
|
||||
async with self._pending_backup_lock:
|
||||
if self.pending_backup:
|
||||
if self.pending_backup.isStale():
|
||||
# The backup is stale, so just let it die.
|
||||
self._killPending()
|
||||
elif self.pending_backup.isComplete() and self.pending_backup.createdSlug() in backups:
|
||||
# Copy over options if we got the requested backup.
|
||||
backups[self.pending_backup.createdSlug()].setOptions(
|
||||
self.pending_backup.getOptions())
|
||||
if self.pending_backup.note() is not None:
|
||||
# Save the note with the now known slug
|
||||
await self.note(backups[self.pending_backup.createdSlug()], self.pending_backup.note())
|
||||
self._killPending()
|
||||
elif self.last_slugs.symmetric_difference(slugs).intersection(slugs):
|
||||
# New backup added, ignore pending backup.
|
||||
sorted = list(backups.values())
|
||||
sorted.sort(key=HABackup.date)
|
||||
if self.pending_backup.note() is not None and len(sorted) > 0:
|
||||
# Save the note with the newest backup
|
||||
await self.note(sorted[-1], self.pending_backup.note())
|
||||
self._killPending()
|
||||
if self.pending_backup:
|
||||
backups[self.pending_backup.slug()] = self.pending_backup
|
||||
for slug in retained:
|
||||
if not self.config.isRetained(slug):
|
||||
self.config.setRetained(slug, False)
|
||||
self._changes_from_last_query = self.last_slugs != slugs
|
||||
self.last_slugs = slugs
|
||||
return backups
|
||||
|
||||
def setDataCacheInfo(self, backup: HABackup):
|
||||
if backup.slug() not in self._data_cache.backups:
|
||||
# its a new backup, so we need to create a record for it
|
||||
pending = self._data_cache.backups.get(KEY_PENDING, {})
|
||||
pending_created = self.time.parse(pending.get(KEY_CREATED, self.time.now().isoformat()))
|
||||
|
||||
# If the backup has the same name as the one we created and it was created within a day
|
||||
# of the requested time, then assume the addon created it.
|
||||
self_created = backup.name() == pending.get(KEY_NAME, None) and abs((pending_created - backup.date()).total_seconds()) < timedelta(days=1).total_seconds()
|
||||
|
||||
stored_backup = self._data_cache.backup(backup.slug())
|
||||
stored_backup[KEY_I_MADE_THIS] = self_created
|
||||
stored_backup[KEY_CREATED] = backup.date().isoformat()
|
||||
stored_backup[KEY_NAME] = backup.name()
|
||||
stored_backup[KEY_NOTE] = backup.note()
|
||||
if self_created:
|
||||
# Remove the pending backup info from the cache so it doesn't get reused.
|
||||
del self._data_cache.backups[KEY_PENDING]
|
||||
# bump the last seen time
|
||||
self._data_cache.backup(backup.slug())[KEY_LAST_SEEN] = self.time.now().isoformat()
|
||||
self._data_cache.makeDirty()
|
||||
|
||||
async def delete(self, backup: Backup):
|
||||
slug = self._validateBackup(backup).slug()
|
||||
logger.info("Deleting '{0}' from Home Assistant".format(backup.name()))
|
||||
await self.harequests.delete(slug)
|
||||
backup.removeSource(self.name())
|
||||
|
||||
async def ignore(self, backup: Backup, ignore: bool):
|
||||
slug = self._validateBackup(backup).slug()
|
||||
logger.info("Updating ignore settings for '{0}'".format(backup.name()))
|
||||
self._data_cache.backup(slug)[KEY_IGNORE] = ignore
|
||||
self._data_cache.makeDirty()
|
||||
|
||||
async def note(self, backup, note: Union[str, None]) -> None:
|
||||
if isinstance(backup, HABackup):
|
||||
validated = backup
|
||||
else:
|
||||
validated = self._validateBackup(backup)
|
||||
logger.debug(f"Adding a note to ha backup '{validated.name()}'")
|
||||
if isinstance(validated, PendingBackup):
|
||||
# The ntoe will get set once the backup is created and we know the slug
|
||||
validated.setNote(note)
|
||||
else:
|
||||
self._data_cache.backup(validated.slug())[KEY_NOTE] = note
|
||||
self._data_cache.makeDirty()
|
||||
validated.setNote(note)
|
||||
return await super().note(backup, note)
|
||||
|
||||
async def save(self, backup: Backup, source: AsyncHttpGetter) -> HABackup:
|
||||
logger.info("Downloading '{0}'".format(backup.name()))
|
||||
self._info.upload(0)
|
||||
resp = None
|
||||
try:
|
||||
backup.overrideStatus("Loading {0}%", source)
|
||||
backup.setUploadSource(self.title(), source)
|
||||
async with source:
|
||||
with aiohttp.MultipartWriter('mixed') as mpwriter:
|
||||
mpwriter.append(source, {'CONTENT-TYPE': 'application/tar'})
|
||||
resp = await self.harequests.upload(mpwriter)
|
||||
backup.clearStatus()
|
||||
backup.clearUploadSource()
|
||||
except Exception as e:
|
||||
logger.printException(e)
|
||||
backup.overrideStatus("Failed!")
|
||||
backup.uploadFailure(logger.formatException(e))
|
||||
if resp and 'slug' in resp and resp['slug'] == backup.slug():
|
||||
self.config.setRetained(backup.slug(), True)
|
||||
return await self.harequests.backup(backup.slug())
|
||||
else:
|
||||
raise UploadFailed()
|
||||
|
||||
async def read(self, backup: Backup) -> IOBase:
|
||||
item = self._validateBackup(backup)
|
||||
return await self.harequests.download(item.slug())
|
||||
|
||||
async def retain(self, backup: Backup, retain: bool) -> None:
|
||||
item: HABackup = self._validateBackup(backup)
|
||||
item._retained = retain
|
||||
self.config.setRetained(backup.slug(), retain)
|
||||
|
||||
async def init(self):
|
||||
await self._refreshInfo()
|
||||
self._initialized = True
|
||||
|
||||
async def refresh(self):
|
||||
await self._refreshInfo()
|
||||
|
||||
async def _refreshInfo(self) -> None:
|
||||
try:
|
||||
self.self_info = await self.harequests.selfInfo()
|
||||
self.host_info = await self.harequests.info()
|
||||
self.ha_info = await self.harequests.haInfo()
|
||||
self.super_info = await self.harequests.supervisorInfo()
|
||||
self.mount_info = await self.harequests.mountInfo()
|
||||
|
||||
addon_info = ensureKey("addons", await self.harequests.getAddons(), "Supervisor Metadata")
|
||||
self.config.update(
|
||||
ensureKey("options", self.self_info, "addon metdata"))
|
||||
if self.config.mustSaveUpgradeChanges():
|
||||
LOGGER.info("The configuration format has changed in this version of the addon and your configuration will be automatically updated")
|
||||
options = {}
|
||||
for option in self.config.getAllConfig().keys():
|
||||
options[option.value] = self.config.get(option)
|
||||
await self.harequests.updateConfig(options)
|
||||
self.config.persistedChanges()
|
||||
|
||||
self._info.ha_port = ensureKey(
|
||||
"port", self.ha_info, "Home Assistant metadata")
|
||||
self._info.ha_ssl = ensureKey(
|
||||
"ssl", self.ha_info, "Home Assistant metadata")
|
||||
self._info.addons = addon_info
|
||||
self._info.slug = ensureKey(
|
||||
"slug", self.self_info, "addon metdata")
|
||||
self._info.url = self.getAddonUrl()
|
||||
|
||||
self._addons = {}
|
||||
for addon in addon_info:
|
||||
self._addons[addon.get('slug', "default")] = addon
|
||||
|
||||
self._info.addDebugInfo("self_info", self.self_info)
|
||||
self._info.addDebugInfo("host_info", self.host_info)
|
||||
self._info.addDebugInfo("ha_info", self.ha_info)
|
||||
self._info.addDebugInfo("super_info", self.super_info)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to connect to supervisor")
|
||||
logger.debug(logger.formatException(e))
|
||||
raise e
|
||||
|
||||
def addonHasLogo(self, slug):
|
||||
return self._addons.get(slug, {}).get('logo', False)
|
||||
|
||||
def getAddonUrl(self):
|
||||
"""
|
||||
Returns the relative path to the add-on, for the purpose of linking to the add-on page from within Home Assistant.
|
||||
"""
|
||||
if self._info.slug is None:
|
||||
return ""
|
||||
return "/hassio/ingress/" + str(self._info.slug)
|
||||
|
||||
def getHostInfo(self):
|
||||
if not self.isInitialized():
|
||||
return {}
|
||||
return self.host_info
|
||||
|
||||
def getFullAddonUrl(self):
|
||||
if not self.isInitialized():
|
||||
return ""
|
||||
return self._haUrl() + "hassio/ingress/" + str(self._info.slug)
|
||||
|
||||
def getHomeAssistantUrl(self):
|
||||
if not self.isInitialized():
|
||||
return ""
|
||||
return self._haUrl()
|
||||
|
||||
def _haUrl(self):
|
||||
if self._info.ha_ssl:
|
||||
protocol = "https://"
|
||||
else:
|
||||
protocol = "http://"
|
||||
return "".join([protocol, "{host}:", str(self._info.ha_port), "/"])
|
||||
|
||||
def _validateBackup(self, backup) -> HABackup:
|
||||
item: HABackup = backup.getSource(self.name())
|
||||
if not item:
|
||||
raise LogicError(
|
||||
"Requested to do something with a backup from Home Assistant, but the backup has no Home Assistant source")
|
||||
return item
|
||||
|
||||
def _killPending(self):
|
||||
self.pending_backup = None
|
||||
if self._pending_backup_task and not self._pending_backup_task.done():
|
||||
self._pending_backup_task.cancel()
|
||||
|
||||
def postSync(self):
|
||||
self.stopper.allowRun()
|
||||
self.stopper.isBackingUp(self.pending_backup is not None)
|
||||
|
||||
async def _requestAsync(self, pending: PendingBackup, start=[]) -> None:
|
||||
try:
|
||||
result = await asyncio.wait_for(self.harequests.createBackup(pending._request_info), timeout=self.config.get(Setting.PENDING_BACKUP_TIMEOUT_SECONDS))
|
||||
slug = ensureKey(
|
||||
"slug", result, "supervisor's create backup response")
|
||||
pending.complete(slug)
|
||||
self.config.setRetained(
|
||||
slug, pending.getOptions().retain_sources.get(self.name(), False))
|
||||
logger.info("Backup finished")
|
||||
except Exception as e:
|
||||
if self._isHttp400(e):
|
||||
logger.warning("A backup was already in progress")
|
||||
pending.setPendingUnknown()
|
||||
else:
|
||||
logger.error("Backup failed:")
|
||||
logger.printException(e)
|
||||
pending.failed(e, self.time.now())
|
||||
finally:
|
||||
await self.stopper.startAddons()
|
||||
self.trigger()
|
||||
|
||||
def _buildBackupInfo(self, options: CreateOptions):
|
||||
addons: List[str] = []
|
||||
for addon in self.super_info.get('addons', {}):
|
||||
addons.append(addon['slug'])
|
||||
request_info: Dict[str, Any] = {
|
||||
'addons': [],
|
||||
'folders': []
|
||||
}
|
||||
folders = list(map(lambda f: f['slug'], FOLDERS))
|
||||
type_name = "Full"
|
||||
for folder in folders:
|
||||
if folder not in self.config.get(Setting.EXCLUDE_FOLDERS):
|
||||
request_info['folders'].append(folder)
|
||||
else:
|
||||
type_name = "Partial"
|
||||
for addon in addons:
|
||||
if addon not in self.config.get(Setting.EXCLUDE_ADDONS):
|
||||
request_info['addons'].append(addon)
|
||||
else:
|
||||
type_name = "Partial"
|
||||
if type_name == "Full":
|
||||
del request_info['addons']
|
||||
del request_info['folders']
|
||||
protected = False
|
||||
password = Password(self.config).resolve()
|
||||
if password:
|
||||
request_info['password'] = password
|
||||
name = BackupName().resolve(type_name, options.name_template,
|
||||
self.time.toLocal(options.when), self.host_info)
|
||||
request_info['name'] = name
|
||||
|
||||
if self.harequests.supportsMountInfo:
|
||||
# Validate the mount location and set it if necessary
|
||||
mount_name = self.config.get(Setting.BACKUP_STORAGE)
|
||||
|
||||
# Default is to use Home Assistant's default configured mount
|
||||
if not mount_name or len(mount_name) == 0:
|
||||
ha_default = self.mount_info.get("default_backup_mount", None)
|
||||
if ha_default:
|
||||
mount_name = ha_default
|
||||
else:
|
||||
mount_name = "local-disk"
|
||||
|
||||
if mount_name != "local-disk":
|
||||
# check to make sure the mount location is valid
|
||||
for mount in self.mount_info.get("mounts", []):
|
||||
if mount.get("name", None) == mount_name:
|
||||
if mount.get("state", False) != "active":
|
||||
raise InactiveNetworkStorageError(mount_name)
|
||||
request_info['location'] = mount_name
|
||||
break
|
||||
if request_info.get('location', None) is None:
|
||||
raise UnknownNetworkStorageError(mount_name)
|
||||
else:
|
||||
request_info['location'] = None
|
||||
return request_info, type_name, protected
|
||||
@@ -0,0 +1,189 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from aiohttp.client_exceptions import ClientResponseError
|
||||
from injector import inject, singleton
|
||||
|
||||
from ..model import Coordinator, Backup
|
||||
from ..config import Config, Setting
|
||||
from ..util import GlobalInfo, Backoff, Estimator
|
||||
from .harequests import HaRequests
|
||||
from ..time import Time
|
||||
from ..worker import Worker
|
||||
from ..const import SOURCE_HA, SOURCE_GOOGLE_DRIVE
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
NOTIFICATION_TITLE = "Home Assistant Google Drive Backup is Having Trouble"
|
||||
NOTIFICATION_DESC_LINK = "The add-on is having trouble making backups and needs attention. Please visit the add-on [status page]({0}) for details."
|
||||
NOTIFICATION_DESC_STATIC = "The add-on is having trouble making backups and needs attention. Please visit the add-on status page for details."
|
||||
|
||||
MAX_BACKOFF = 60 * 5 # 5 minutes
|
||||
FIRST_BACKOFF = 60 # 1 minute
|
||||
|
||||
# Wait 5 minutes before logging
|
||||
NOTIFY_DELAY = 60 * 5 # 5 minute
|
||||
|
||||
OLD_BACKUP_ENTITY_NAME = "sensor.snapshot_backup"
|
||||
BACKUP_ENTITY_NAME = "sensor.backup_state"
|
||||
|
||||
REASSURING_MESSAGE = "Unable to reach Home Assistant (HTTP {0}). This is normal if Home Assistant is restarting. You will probably see some errors in the supervisor logs until it comes back online."
|
||||
|
||||
|
||||
@singleton
|
||||
class HaUpdater(Worker):
|
||||
@inject
|
||||
def __init__(self, requests: HaRequests, coordinator: Coordinator, config: Config, time: Time, global_info: GlobalInfo):
|
||||
self._config = config
|
||||
super().__init__("Sensor Updater", self.update, time, self.getInterval)
|
||||
self._time = time
|
||||
self._coordinator = coordinator
|
||||
self._requests: HaRequests = requests
|
||||
self._info = global_info
|
||||
self._notified = False
|
||||
self._backoff = Backoff(max=MAX_BACKOFF, base=FIRST_BACKOFF)
|
||||
self._first_error = None
|
||||
self._trigger_once = False
|
||||
|
||||
self._last_backup_update = None
|
||||
self.last_backup_update_time = time.now() - timedelta(days=1)
|
||||
self._config.subscribe(self.config_updated)
|
||||
self._last_interval = self.getInterval()
|
||||
|
||||
def config_updated(self):
|
||||
if self._last_interval != self.getInterval():
|
||||
self._wait_event.set()
|
||||
self._last_interval = self.getInterval()
|
||||
|
||||
def getInterval(self):
|
||||
return self._config.get(Setting.HA_REPORTING_INTERVAL_SECONDS)
|
||||
|
||||
async def update(self):
|
||||
try:
|
||||
if self._config.get(Setting.ENABLE_BACKUP_STALE_SENSOR):
|
||||
await self._requests.updateBackupStaleSensor('on' if self._stale() else 'off')
|
||||
if self._config.get(Setting.ENABLE_BACKUP_STATE_SENSOR):
|
||||
await self._maybeSendBackupUpdate()
|
||||
if self._config.get(Setting.NOTIFY_FOR_STALE_BACKUPS):
|
||||
if self._stale() and not self._notified:
|
||||
if self._info.url is None or len(self._info.url) == 0:
|
||||
message = NOTIFICATION_DESC_STATIC
|
||||
else:
|
||||
message = NOTIFICATION_DESC_LINK.format(self._info.url)
|
||||
await self._requests.sendNotification(NOTIFICATION_TITLE, message)
|
||||
self._notified = True
|
||||
elif not self._stale() and self._notified:
|
||||
await self._requests.dismissNotification()
|
||||
self._notified = False
|
||||
self._backoff.reset()
|
||||
self._first_error = None
|
||||
self._trigger_once = False
|
||||
except ClientResponseError as e:
|
||||
if self._first_error is None:
|
||||
self._first_error = self._time.now()
|
||||
if int(e.status / 100) == 5:
|
||||
if self._time.now() > self._first_error + timedelta(seconds=NOTIFY_DELAY):
|
||||
logger.error(
|
||||
"Unable to reach Home Assistant (HTTP {0}). This is normal if Home Assistant is restarting. You will probably see some errors in the supervisor logs until it comes back online.".format(e.status))
|
||||
else:
|
||||
logger.error("Trouble updating Home Assistant sensors.")
|
||||
self._last_backup_update = None
|
||||
await self._time.sleepAsync(self._backoff.backoff(e))
|
||||
except Exception as e:
|
||||
self._last_backup_update = None
|
||||
logger.error("Trouble updating Home Assistant sensors.")
|
||||
logger.printException(e)
|
||||
await self._time.sleepAsync(self._backoff.backoff(e))
|
||||
|
||||
async def _maybeSendBackupUpdate(self):
|
||||
update = self._buildBackupUpdate()
|
||||
if self._trigger_once or update != self._last_backup_update or self._time.now() > self.last_backup_update_time + timedelta(hours=1):
|
||||
if self._config.get(Setting.CALL_BACKUP_SNAPSHOT):
|
||||
await self._requests.updateEntity(OLD_BACKUP_ENTITY_NAME, update)
|
||||
else:
|
||||
await self._requests.updateEntity(BACKUP_ENTITY_NAME, update)
|
||||
self._last_backup_update = update
|
||||
self.last_backup_update_time = self._time.now()
|
||||
|
||||
def _stale(self):
|
||||
if self._info._first_sync:
|
||||
return False
|
||||
if self._info._last_error:
|
||||
return self._time.now() > self._info._last_success + timedelta(seconds=self._config.get(Setting.BACKUP_STALE_SECONDS))
|
||||
else:
|
||||
next_backup = self._coordinator.nextBackupTime(include_pending=False)
|
||||
if not next_backup:
|
||||
# no backups are configured
|
||||
return False
|
||||
|
||||
# Determine if a lot of time has passed since the last backup "should" have been made.
|
||||
warn_after = next_backup + timedelta(seconds=self._config.get(Setting.LONG_TERM_STALE_BACKUP_SECONDS))
|
||||
return self._time.now() >= warn_after
|
||||
|
||||
def _state(self):
|
||||
if self._stale():
|
||||
return "error"
|
||||
else:
|
||||
return "waiting" if self._info._first_sync else "backed_up"
|
||||
|
||||
def triggerRefresh(self):
|
||||
self._trigger_once = True
|
||||
|
||||
def _buildBackupUpdate(self):
|
||||
backups = list(filter(lambda s: not s.ignore(), self._coordinator.backups()))
|
||||
last = "Never"
|
||||
if len(backups) > 0:
|
||||
last = max(backups, key=lambda s: s.date()).date().isoformat()
|
||||
|
||||
def makeBackupData(backup: Backup):
|
||||
return {
|
||||
"name": backup.name(),
|
||||
"date": str(backup.date().isoformat()),
|
||||
"state": backup.status(),
|
||||
"size": backup.sizeString(),
|
||||
"slug": backup.slug()
|
||||
}
|
||||
ha_backups = list(filter(lambda s: s.getSource(SOURCE_HA) is not None, backups))
|
||||
drive_backups = list(filter(lambda s: s.getSource(SOURCE_GOOGLE_DRIVE) is not None, backups))
|
||||
|
||||
last_uploaded = "Never"
|
||||
if len(drive_backups) > 0:
|
||||
last_uploaded = max(drive_backups, key=lambda s: s.date()).date().isoformat()
|
||||
if self._config.get(Setting.CALL_BACKUP_SNAPSHOT):
|
||||
return {
|
||||
"state": self._state(),
|
||||
"attributes": {
|
||||
"friendly_name": "Snapshot State",
|
||||
"last_snapshot": last, # type: ignore
|
||||
"snapshots_in_google_drive": len(drive_backups),
|
||||
"snapshots_in_hassio": len(ha_backups),
|
||||
"snapshots_in_home_assistant": len(ha_backups),
|
||||
"size_in_google_drive": Estimator.asSizeString(sum(map(lambda v: v.sizeInt(), drive_backups))),
|
||||
"size_in_home_assistant": Estimator.asSizeString(sum(map(lambda v: v.sizeInt(), ha_backups))),
|
||||
"snapshots": list(map(makeBackupData, backups))
|
||||
}
|
||||
}
|
||||
else:
|
||||
source_metrics = self._coordinator.buildBackupMetrics()
|
||||
next = self._coordinator.nextBackupTime()
|
||||
if next is not None:
|
||||
next = next.isoformat()
|
||||
attr = {
|
||||
"friendly_name": "Backup State",
|
||||
"last_backup": last, # type: ignore
|
||||
"next_backup": next,
|
||||
"last_uploaded": last_uploaded,
|
||||
"backups_in_google_drive": len(drive_backups),
|
||||
"backups_in_home_assistant": len(ha_backups),
|
||||
"size_in_google_drive": Estimator.asSizeString(sum(map(lambda v: v.sizeInt(), drive_backups))),
|
||||
"size_in_home_assistant": Estimator.asSizeString(sum(map(lambda v: v.sizeInt(), ha_backups))),
|
||||
"backups": list(map(makeBackupData, backups))
|
||||
}
|
||||
if SOURCE_GOOGLE_DRIVE in source_metrics and 'free_space' in source_metrics[SOURCE_GOOGLE_DRIVE]:
|
||||
attr["free_space_in_google_drive"] = source_metrics[SOURCE_GOOGLE_DRIVE]['free_space']
|
||||
else:
|
||||
attr["free_space_in_google_drive"] = ""
|
||||
return {
|
||||
"state": self._state(),
|
||||
"attributes": attr
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
from ..config import Config, Setting
|
||||
from ..exceptions import BackupPasswordKeyInvalid
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class Password():
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
|
||||
def resolve(self, password=None):
|
||||
if password is None:
|
||||
password = self.config.get(Setting.BACKUP_PASSWORD)
|
||||
if len(password) == 0:
|
||||
return None
|
||||
if password.startswith("!secret "):
|
||||
if not os.path.isfile(self.config.get(Setting.SECRETS_FILE_PATH)):
|
||||
raise BackupPasswordKeyInvalid()
|
||||
with open(self.config.get(Setting.SECRETS_FILE_PATH)) as f:
|
||||
secrets_yaml = yaml.load(f, Loader=yaml.SafeLoader)
|
||||
key = password[len("!secret "):]
|
||||
if key not in secrets_yaml:
|
||||
raise BackupPasswordKeyInvalid()
|
||||
return str(secrets_yaml[key])
|
||||
else:
|
||||
return password
|
||||
@@ -0,0 +1,217 @@
|
||||
import logging
|
||||
from logging import LogRecord, Formatter, ERROR
|
||||
from traceback import TracebackException
|
||||
from colorlog import ColoredFormatter
|
||||
from os.path import join, abspath
|
||||
|
||||
HISTORY_SIZE = 1000
|
||||
PATH_BASE = abspath(join(__file__, "..", ".."))
|
||||
|
||||
logging.addLevelName(5, "TRACE")
|
||||
logging.TRACE = 5
|
||||
|
||||
|
||||
class HistoryHandler(logging.Handler):
|
||||
def __init__(self):
|
||||
super(HistoryHandler, self).__init__()
|
||||
self.history = [None] * HISTORY_SIZE
|
||||
self.history_index = 0
|
||||
|
||||
def reset(self):
|
||||
self.history = [None] * HISTORY_SIZE
|
||||
self.history_index = 0
|
||||
|
||||
def emit(self, record: LogRecord):
|
||||
self.history[self.history_index % HISTORY_SIZE] = record
|
||||
self.history_index += 1
|
||||
|
||||
def getHistory(self, start=0, html=False):
|
||||
end = self.history_index
|
||||
if end - start >= HISTORY_SIZE:
|
||||
start = end - HISTORY_SIZE
|
||||
for x in range(start, end):
|
||||
item = self.history[x % HISTORY_SIZE]
|
||||
if html:
|
||||
if item.levelno == logging.WARN:
|
||||
style = "console-warning"
|
||||
elif item.levelno == logging.ERROR:
|
||||
style = "console-error"
|
||||
elif item.levelno == logging.DEBUG:
|
||||
style = "console-debug"
|
||||
elif item.levelno == logging.CRITICAL:
|
||||
style = "console-critical"
|
||||
elif item.levelno == logging.FATAL:
|
||||
style = "console-fatal"
|
||||
elif item.levelno == logging.WARNING:
|
||||
style = "console-warning"
|
||||
elif item.levelno == logging.TRACE:
|
||||
style = "console-trace"
|
||||
else:
|
||||
style = "console-default"
|
||||
line = "<span class='" + style + \
|
||||
"'>" + self.format(item) + "</span>"
|
||||
yield (x + 1, line)
|
||||
else:
|
||||
yield (x + 1, self.format(item))
|
||||
|
||||
def getLast(self) -> LogRecord:
|
||||
return self.history[(self.history_index - 1) % HISTORY_SIZE]
|
||||
|
||||
|
||||
CONSOLE = logging.StreamHandler()
|
||||
CONSOLE.setLevel(logging.INFO)
|
||||
formatter_color = ColoredFormatter(
|
||||
'%(log_color)s%(asctime)s %(levelname)s %(message)s%(reset)s',
|
||||
datefmt='%m-%d %H:%M:%S',
|
||||
reset=True,
|
||||
log_colors={
|
||||
"DEBUG": "cyan",
|
||||
"INFO": "green",
|
||||
"WARNING": "yellow",
|
||||
"ERROR": "red",
|
||||
"CRITICAL": "red",
|
||||
"TRACE": "white",
|
||||
},
|
||||
)
|
||||
CONSOLE.setFormatter(formatter_color)
|
||||
|
||||
HISTORY = HistoryHandler()
|
||||
HISTORY.setLevel(logging.DEBUG)
|
||||
HISTORY.setFormatter(Formatter('%(asctime)s %(levelname)s [%(name)s] %(message)s', '%m-%d %H:%M:%S'))
|
||||
|
||||
|
||||
class StandardLogger(logging.Logger):
|
||||
def __init__(self, name):
|
||||
super().__init__(name)
|
||||
self.setLevel(logging.TRACE)
|
||||
self.addHandler(CONSOLE)
|
||||
self.addHandler(HISTORY)
|
||||
|
||||
def trace(self, msg, *args, **kwargs):
|
||||
self.log(logging.TRACE, msg, *args, **kwargs)
|
||||
|
||||
def printException(self, ex: Exception, level=ERROR):
|
||||
self.log(level, self.formatException(ex))
|
||||
|
||||
def formatException(self, e: Exception) -> str:
|
||||
trace = None
|
||||
if (hasattr(e, "__traceback__")):
|
||||
trace = e.__traceback__
|
||||
tbe = TracebackException(type(e), e, trace, limit=None)
|
||||
lines = list(self._format(tbe))
|
||||
return '\n%s' % ''.join(lines)
|
||||
|
||||
def _format(self, tbe):
|
||||
if (tbe.__context__ is not None and not tbe.__suppress_context__):
|
||||
yield from self._format(tbe.__context__)
|
||||
yield "Whose handling caused:\n"
|
||||
is_addon, stack = self._formatStack(tbe)
|
||||
yield from stack
|
||||
yield from tbe.format_exception_only()
|
||||
|
||||
def _formatStack(self, tbe):
|
||||
_RECURSIVE_CUTOFF = 3
|
||||
result = []
|
||||
last_file = None
|
||||
last_line = None
|
||||
last_name = None
|
||||
count = 0
|
||||
is_addon = False
|
||||
buffer = []
|
||||
for frame in tbe.stack:
|
||||
line_internal = True
|
||||
if (last_file is None or last_file != frame.filename or last_line is None or last_line != frame.lineno or last_name is None or last_name != frame.name):
|
||||
if count > _RECURSIVE_CUTOFF:
|
||||
count -= _RECURSIVE_CUTOFF
|
||||
result.append(
|
||||
f' [Previous line repeated {count} more '
|
||||
f'time{"s" if count > 1 else ""}]\n'
|
||||
)
|
||||
last_file = frame.filename
|
||||
last_line = frame.lineno
|
||||
last_name = frame.name
|
||||
count = 0
|
||||
count += 1
|
||||
if count > _RECURSIVE_CUTOFF:
|
||||
continue
|
||||
fileName = frame.filename
|
||||
pos = fileName.rfind(PATH_BASE)
|
||||
if pos >= 0:
|
||||
is_addon = True
|
||||
line_internal = False
|
||||
fileName = "addon" + \
|
||||
fileName[pos + len(PATH_BASE):]
|
||||
|
||||
pos = fileName.rfind("site-packages")
|
||||
if pos > 0:
|
||||
fileName = fileName[pos - 1:]
|
||||
|
||||
pos = fileName.rfind("python3.7")
|
||||
if pos > 0:
|
||||
fileName = fileName[pos - 1:]
|
||||
pass
|
||||
line = ' {}:{} ({})\n'.format(fileName, frame.lineno, frame.name)
|
||||
if line_internal:
|
||||
buffer.append(line)
|
||||
else:
|
||||
result.extend(self._compressFrames(buffer))
|
||||
buffer = []
|
||||
result.append(line)
|
||||
if count > _RECURSIVE_CUTOFF:
|
||||
count -= _RECURSIVE_CUTOFF
|
||||
result.append(
|
||||
f' [Previous line repeated {count} more '
|
||||
f'time{"s" if count > 1 else ""}]\n'
|
||||
)
|
||||
result.extend(self._compressFrames(buffer))
|
||||
return is_addon, result
|
||||
|
||||
def overrideLevel(self, console, history):
|
||||
CONSOLE.setLevel(console)
|
||||
HISTORY.setLevel(history)
|
||||
|
||||
def _compressFrames(self, buffer):
|
||||
if len(buffer) > 1:
|
||||
yield buffer[0]
|
||||
if len(buffer) == 3:
|
||||
yield buffer[1]
|
||||
elif len(buffer) > 2:
|
||||
yield " [{} hidden frames]\n".format(len(buffer) - 2)
|
||||
yield buffer[len(buffer) - 1]
|
||||
elif len(buffer) > 0:
|
||||
yield buffer[len(buffer) - 1]
|
||||
pass
|
||||
|
||||
|
||||
def getLogger(name):
|
||||
return StandardLogger(name)
|
||||
|
||||
|
||||
def getHistory(index, html):
|
||||
return HISTORY.getHistory(index, html)
|
||||
|
||||
|
||||
def getLast() -> LogRecord:
|
||||
return HISTORY.getLast()
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
return HISTORY.reset()
|
||||
|
||||
|
||||
class TraceLogger(StandardLogger):
|
||||
def __init__(self, name):
|
||||
super().__init__(name)
|
||||
self.setLevel(logging.TRACE)
|
||||
|
||||
def log(self, lvl, msg, *args, **kwargs):
|
||||
super().log(logging.TRACE, msg, *args, **kwargs)
|
||||
|
||||
def info(self, *args, **kwargs):
|
||||
super().log(logging.TRACE, *args, **kwargs)
|
||||
|
||||
def error(self, *args, **kwargs):
|
||||
super().log(logging.TRACE, *args, **kwargs)
|
||||
|
||||
def warn(self, *args, **kwargs):
|
||||
super().log(logging.TRACE, *args, **kwargs)
|
||||
@@ -0,0 +1,13 @@
|
||||
# flake8: noqa
|
||||
from .backupscheme import GenerationalScheme, OldestScheme, GenConfig, BackupScheme
|
||||
from .coordinator import Coordinator
|
||||
from .model import BackupSource, BackupDestination, Model
|
||||
from .syncer import Scyncer
|
||||
from .backups import AbstractBackup, Backup
|
||||
from .drivebackup import DriveBackup
|
||||
from .dummybackup import DummyBackup
|
||||
from .dummybackupsource import DummyBackupSource
|
||||
from .habackup import HABackup
|
||||
from .simulatedsource import SimulatedSource
|
||||
from .precache import Precache
|
||||
from .destinationprecache import DestinationPrecache
|
||||
@@ -0,0 +1,318 @@
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional, Union
|
||||
from dateutil.tz import tzutc
|
||||
from ..util import Estimator
|
||||
|
||||
from ..const import SOURCE_GOOGLE_DRIVE, SOURCE_HA
|
||||
from ..logger import getLogger
|
||||
from ..config import CreateOptions
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
PROP_TYPE = "type"
|
||||
PROP_VERSION = "version"
|
||||
PROP_PROTECTED = "protected"
|
||||
PROP_RETAINED = "retained"
|
||||
PROP_NOTE = "note"
|
||||
|
||||
DRIVE_KEY_TEXT = "Google Drive's backup metadata"
|
||||
HA_KEY_TEXT = "Home Assistant's backup metadata"
|
||||
|
||||
|
||||
class AbstractBackup():
|
||||
def __init__(self, name: str, slug: str, source: str, date: str, size: int, version: str, backupType: str, protected: bool, note=None, retained: bool = False, uploadable: bool = False, details={}, pending=False):
|
||||
self._options = None
|
||||
self._name = name
|
||||
self._slug = slug
|
||||
self._source = source
|
||||
self._date = date
|
||||
self._size = size
|
||||
self._retained = retained
|
||||
self._uploadable = uploadable
|
||||
self._details = details
|
||||
self._version = version
|
||||
self._backupType = backupType
|
||||
self._protected = protected
|
||||
self._ignore = False
|
||||
self._note = note
|
||||
self._pending = pending
|
||||
|
||||
def isPending(self):
|
||||
return self._pending
|
||||
|
||||
def setOptions(self, options: CreateOptions):
|
||||
self._options = options
|
||||
|
||||
def getOptions(self) -> CreateOptions:
|
||||
return self._options
|
||||
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def slug(self) -> str:
|
||||
return self._slug
|
||||
|
||||
def size(self) -> int:
|
||||
return self._size
|
||||
|
||||
def note(self) -> Union[str, None]:
|
||||
return self._note
|
||||
|
||||
def sizeInt(self) -> int:
|
||||
try:
|
||||
return int(self.size())
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
def date(self) -> datetime:
|
||||
return self._date
|
||||
|
||||
def source(self) -> str:
|
||||
return self._source
|
||||
|
||||
def retained(self) -> str:
|
||||
return self._retained
|
||||
|
||||
def version(self):
|
||||
return self._version
|
||||
|
||||
def backupType(self):
|
||||
return self._backupType
|
||||
|
||||
def protected(self):
|
||||
return self._protected
|
||||
|
||||
def setRetained(self, retained):
|
||||
self._retained = retained
|
||||
|
||||
def uploadable(self) -> bool:
|
||||
return self._uploadable
|
||||
|
||||
def considerForPurge(self) -> bool:
|
||||
return not self.retained()
|
||||
|
||||
def setUploadable(self, uploadable):
|
||||
self._uploadable = uploadable
|
||||
|
||||
def details(self):
|
||||
return self._details
|
||||
|
||||
def setNote(self, note: Union[str, None]):
|
||||
self._note = note
|
||||
|
||||
def status(self):
|
||||
return None
|
||||
|
||||
def madeByTheAddon(self):
|
||||
return True
|
||||
|
||||
def ignore(self):
|
||||
return self._ignore
|
||||
|
||||
def setIgnore(self, ignore):
|
||||
self._ignore = ignore
|
||||
|
||||
|
||||
class Backup(object):
|
||||
"""
|
||||
Represents a Home Assistant backup stored on Google Drive, locally in
|
||||
Home Assistant, or a pending backup we expect to see show up later
|
||||
"""
|
||||
|
||||
def __init__(self, backup: Optional[AbstractBackup] = None):
|
||||
self.sources: Dict[str, AbstractBackup] = {}
|
||||
self._purgeNext: Dict[str, bool] = {}
|
||||
self._options = None
|
||||
self._status_override = None
|
||||
self._status_override_args = None
|
||||
self._state_detail = None
|
||||
self._upload_source = None
|
||||
self._upload_source_name = None
|
||||
self._upload_fail_info = None
|
||||
if backup is not None:
|
||||
self.addSource(backup)
|
||||
|
||||
def setOptions(self, options):
|
||||
self._options = options
|
||||
|
||||
def getOptions(self):
|
||||
return self._options
|
||||
|
||||
def updatePurge(self, source: str, purge: bool):
|
||||
self._purgeNext[source] = purge
|
||||
|
||||
def addSource(self, backup: AbstractBackup):
|
||||
self.sources[backup.source()] = backup
|
||||
if backup.getOptions() and not self.getOptions():
|
||||
self.setOptions(backup.getOptions())
|
||||
|
||||
def getStatusDetail(self):
|
||||
return self._state_detail
|
||||
|
||||
def setStatusDetail(self, info):
|
||||
self._state_detail = info
|
||||
|
||||
def removeSource(self, source):
|
||||
if source in self.sources:
|
||||
del self.sources[source]
|
||||
if source in self._purgeNext:
|
||||
del self._purgeNext[source]
|
||||
|
||||
def getPurges(self):
|
||||
return self._purgeNext
|
||||
|
||||
def uploadInfo(self):
|
||||
if not self._upload_source:
|
||||
return {}
|
||||
elif self._upload_source.progress() == 100:
|
||||
return {}
|
||||
else:
|
||||
return {
|
||||
'progress': self._upload_source.progress()
|
||||
}
|
||||
|
||||
def getSource(self, source: str):
|
||||
return self.sources.get(source, None)
|
||||
|
||||
def name(self):
|
||||
for backup in self.sources.values():
|
||||
return backup.name()
|
||||
return "error"
|
||||
|
||||
def note(self):
|
||||
longest = None
|
||||
for backup in self.sources.values():
|
||||
if backup.note() is not None and (longest is None or len(backup.note()) > len(longest)):
|
||||
longest = backup.note()
|
||||
return longest
|
||||
|
||||
def slug(self) -> str:
|
||||
for backup in self.sources.values():
|
||||
return backup.slug()
|
||||
return "error"
|
||||
|
||||
def size(self) -> int:
|
||||
for backup in self.sources.values():
|
||||
return backup.size()
|
||||
return 0
|
||||
|
||||
def sizeInt(self) -> int:
|
||||
for backup in self.sources.values():
|
||||
return backup.sizeInt()
|
||||
return 0
|
||||
|
||||
def backupType(self) -> str:
|
||||
for backup in self.sources.values():
|
||||
return backup.backupType()
|
||||
return "error"
|
||||
|
||||
def version(self) -> Union[str, None]:
|
||||
for backup in self.sources.values():
|
||||
if backup.version() is not None:
|
||||
return backup.version()
|
||||
return None
|
||||
|
||||
def details(self):
|
||||
for backup in self.sources.values():
|
||||
if backup.details() is not None:
|
||||
return backup.details()
|
||||
return {}
|
||||
|
||||
def getUploadInfo(self, time):
|
||||
if self._upload_source_name is None:
|
||||
return None
|
||||
ret = {
|
||||
'name': self._upload_source_name
|
||||
}
|
||||
if self._upload_fail_info:
|
||||
ret['failure'] = self._upload_fail_info
|
||||
elif self._upload_source is not None:
|
||||
ret['progress'] = self._upload_source.progress()
|
||||
ret['speed'] = self._upload_source.speed(timedelta(seconds=20))
|
||||
ret['total'] = self._upload_source.position()
|
||||
ret['started'] = time.formatDelta(self._upload_source.startTime())
|
||||
return ret
|
||||
|
||||
def protected(self) -> bool:
|
||||
for backup in self.sources.values():
|
||||
return backup.protected()
|
||||
return False
|
||||
|
||||
def ignore(self) -> bool:
|
||||
for backup in self.sources.values():
|
||||
if not backup.ignore():
|
||||
return False
|
||||
return True
|
||||
|
||||
def date(self) -> datetime:
|
||||
for backup in self.sources.values():
|
||||
return backup.date()
|
||||
return datetime.now(tzutc())
|
||||
|
||||
def sizeString(self) -> str:
|
||||
size_string = self.size()
|
||||
if type(size_string) == str:
|
||||
return size_string
|
||||
return Estimator.asSizeString(size_string)
|
||||
|
||||
def status(self) -> str:
|
||||
# TODO: Drive Specific
|
||||
if self._status_override is not None:
|
||||
return self._status_override.format(*self._status_override_args)
|
||||
|
||||
for backup in self.sources.values():
|
||||
status = backup.status()
|
||||
if status:
|
||||
return status
|
||||
|
||||
inDrive = self.getSource(SOURCE_GOOGLE_DRIVE) is not None
|
||||
inHa = self.getSource(SOURCE_HA) is not None
|
||||
|
||||
if inDrive and inHa:
|
||||
return "Backed Up"
|
||||
if inDrive:
|
||||
return "Drive Only"
|
||||
if inHa:
|
||||
return "HA Only"
|
||||
return "Deleted"
|
||||
|
||||
def isDeleted(self) -> bool:
|
||||
return len(self.sources) == 0
|
||||
|
||||
def overrideStatus(self, format, *args) -> None:
|
||||
self._status_override = format
|
||||
self._status_override_args = args
|
||||
|
||||
def setUploadSource(self, source_name: str, source):
|
||||
self._upload_source = source
|
||||
self._upload_source_name = source_name
|
||||
self._upload_fail_info = None
|
||||
|
||||
def clearUploadSource(self):
|
||||
self._upload_source = None
|
||||
self._upload_source_name = None
|
||||
self._upload_fail_info = None
|
||||
|
||||
def uploadFailure(self, info):
|
||||
self._upload_source = None
|
||||
self._upload_fail_info = info
|
||||
|
||||
def clearStatus(self):
|
||||
self._status_override = None
|
||||
self._status_override_args = None
|
||||
|
||||
def isPending(self):
|
||||
for backup in self.sources.values():
|
||||
if backup.isPending():
|
||||
return True
|
||||
return False
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "<Slug: {0} {1} {2}>".format(self.slug(), " ".join(self.sources), self.date().isoformat())
|
||||
|
||||
def __format__(self, format_spec: str) -> str:
|
||||
return self.__str__()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
@@ -0,0 +1,236 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from calendar import monthrange
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import List, Optional, Sequence, Set, Tuple, Any, Union
|
||||
|
||||
from .backups import Backup
|
||||
from backup.util import RangeLookup
|
||||
from ..time import Time
|
||||
from ..config import GenConfig
|
||||
from ..logger import getLogger
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class BackupScheme(ABC):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def getOldest(self, backups: Sequence[Backup]) -> Tuple[str, Optional[Backup]]:
|
||||
pass
|
||||
|
||||
def handleNaming(self, backups: Sequence[Backup]) -> None:
|
||||
for backup in backups:
|
||||
backup.setStatusDetail(None)
|
||||
|
||||
|
||||
class DeleteAfterUploadScheme(BackupScheme):
|
||||
def __init__(self, source: str, destinations: List[str]):
|
||||
self.source = source
|
||||
self.destinations = destinations
|
||||
|
||||
def getOldest(self, backups: List[Backup]):
|
||||
consider = []
|
||||
for backup in backups:
|
||||
uploaded = True
|
||||
if backup.getSource(self.source) is None:
|
||||
# No source, so ignore it
|
||||
uploaded = False
|
||||
for destination in self.destinations:
|
||||
if backup.getSource(destination) is None:
|
||||
# its not in destination, so ignore it
|
||||
uploaded = False
|
||||
if uploaded:
|
||||
consider.append(backup)
|
||||
|
||||
# Delete the oldest first
|
||||
return OldestScheme().getOldest(consider)
|
||||
|
||||
|
||||
class OldestScheme(BackupScheme):
|
||||
def __init__(self, count=0):
|
||||
self.count = count
|
||||
|
||||
def getOldest(self, backups: Sequence[Backup]) -> Tuple[Any, Union[Backup, None]]:
|
||||
if len(backups) <= self.count:
|
||||
return None, None
|
||||
return "default", min(backups, default=None, key=lambda s: s.date())
|
||||
|
||||
def handleNaming(self, backups: Sequence[Backup]) -> None:
|
||||
for backup in backups:
|
||||
backup.setStatusDetail(None)
|
||||
|
||||
|
||||
class Partition(object):
|
||||
def __init__(self, start: datetime, end: datetime, prefer: datetime, time: Time, details=None, delete_only: bool = False):
|
||||
self.start: datetime = start
|
||||
self.end: datetime = end
|
||||
self.prefer: datetime = prefer
|
||||
self.time = time
|
||||
self.details = details
|
||||
self.selected = None
|
||||
self._delete_only_partitions = delete_only
|
||||
|
||||
def select(self, backups: List[Backup]) -> Optional[Backup]:
|
||||
options = list(RangeLookup(backups, lambda s: s.date()).matches(self.start, self.end - timedelta(milliseconds=1)))
|
||||
|
||||
searcher = lambda s: self.day(s.date()) == self.day(self.prefer)
|
||||
|
||||
preferred = list(filter(searcher, options))
|
||||
if len(preferred) > 0:
|
||||
# If there is a backup on the "preferred" day, then use the latest backup on that day
|
||||
self.selected = max(preferred, default=None, key=Backup.date)
|
||||
else:
|
||||
# Otherwise, use the earliest backup over the valid period.
|
||||
self.selected = min(options, default=None, key=Backup.date)
|
||||
return self.selected
|
||||
|
||||
def delta(self) -> timedelta:
|
||||
return self.end - self.start
|
||||
|
||||
def day(self, date: datetime):
|
||||
# TODO: this conversion isn't time-zone safe, but is ok because we only use it to compare local day to local day.
|
||||
local = self.time.toLocal(date)
|
||||
return datetime(day=local.day, month=local.month, year=local.year)
|
||||
|
||||
# True if the partition exists only to determine why a snapshot is getting deleted.
|
||||
@property
|
||||
def is_delete_only(self):
|
||||
return self._delete_only_partitions
|
||||
|
||||
def __hash__(self):
|
||||
"""Overrides the default implementation"""
|
||||
return hash(tuple(sorted(self.__dict__.items())))
|
||||
|
||||
|
||||
class GenerationalScheme(BackupScheme):
|
||||
def __init__(self, time: Time, config: GenConfig, count=0):
|
||||
self.count = count
|
||||
self.time: Time = time
|
||||
self.config = config
|
||||
|
||||
def _buildPartitions(self, backups_input):
|
||||
backups: List[Backup] = list(backups_input)
|
||||
|
||||
# build the list of dates we should partition by
|
||||
day_of_week = 3
|
||||
weekday_lookup = {
|
||||
'mon': 0,
|
||||
'tue': 1,
|
||||
'wed': 2,
|
||||
'thu': 3,
|
||||
'fri': 4,
|
||||
'sat': 5,
|
||||
'sun': 6,
|
||||
}
|
||||
if self.config.day_of_week in weekday_lookup:
|
||||
day_of_week = weekday_lookup[self.config.day_of_week]
|
||||
|
||||
last = self.time.toLocal(backups[len(backups) - 1].date())
|
||||
lookups: List[Partition] = []
|
||||
currentDay = self.day(last)
|
||||
if self.config.days > 0:
|
||||
for x in range(0, self.config.days + 1):
|
||||
nextDay = self.day(currentDay, add_days=1)
|
||||
lookups.append(
|
||||
Partition(currentDay, nextDay, currentDay, self.time, "Day {0} of {1}".format(x + 1, self.config.days), delete_only=(x >= self.config.days)))
|
||||
currentDay = self.day(currentDay, add_days=-1)
|
||||
|
||||
if self.config.weeks > 0:
|
||||
for x in range(0, self.config.weeks + 1):
|
||||
# Start at the first monday preceeding the last backup
|
||||
start = self.time.local(last.year, last.month, last.day)
|
||||
start = self.day(start, add_days=-1 * start.weekday())
|
||||
|
||||
# Move back x weeks
|
||||
start = self.day(start, add_days=-7 * x)
|
||||
end = self.day(start, add_days=7)
|
||||
|
||||
# Only consider backups from that week after the start day
|
||||
# TODO: should this actually "prefer" the day of week but start on monday?
|
||||
start = self.day(start, add_days=day_of_week)
|
||||
lookups.append(Partition(start, end, start, self.time, "Week {0} of {1}".format(x + 1, self.config.weeks), delete_only=(x >= self.config.weeks)))
|
||||
|
||||
if self.config.months > 0:
|
||||
for x in range(0, self.config.months + 1):
|
||||
year_offset = int(x / 12)
|
||||
month_offset = int(x % 12)
|
||||
if last.month - month_offset < 1:
|
||||
year_offset = year_offset + 1
|
||||
month_offset = month_offset - 12
|
||||
start = self.time.local(
|
||||
last.year - year_offset, last.month - month_offset, 1)
|
||||
weekday, days = monthrange(start.year, start.month)
|
||||
end = start + timedelta(days=days)
|
||||
lookups.append(Partition(
|
||||
start, end, start + timedelta(days=self.config.day_of_month - 1), self.time,
|
||||
"{0} ({1} of {2} months)".format(start.strftime("%B"), x + 1, self.config.months), delete_only=(x >= self.config.months)))
|
||||
|
||||
if self.config.years > 0:
|
||||
for x in range(0, self.config.years + 1):
|
||||
start = self.time.local(last.year - x, 1, 1)
|
||||
end = self.time.local(last.year - x + 1, 1, 1)
|
||||
lookups.append(Partition(
|
||||
start, end, start + timedelta(days=self.config.day_of_year - 1), self.time,
|
||||
"{0} ({1} of {2} years)".format(start.strftime("%Y"), x + 1, self.config.years), delete_only=(x >= self.config.years)))
|
||||
|
||||
# Keep track of which backups are being saved for which time period.
|
||||
for lookup in lookups:
|
||||
lookup.select(backups)
|
||||
return lookups
|
||||
|
||||
def getOldest(self, backups: Sequence[Backup]):
|
||||
if len(backups) == 0:
|
||||
return None, None
|
||||
|
||||
sorted = list(backups)
|
||||
sorted.sort(key=lambda s: s.date())
|
||||
|
||||
partitions = self._buildPartitions(sorted)
|
||||
keepers: Set[Backup] = set()
|
||||
for part in partitions:
|
||||
if part.selected is not None and not part.is_delete_only:
|
||||
keepers.add(part.selected)
|
||||
|
||||
extras = []
|
||||
for backup in sorted:
|
||||
if backup not in keepers:
|
||||
extras.append(backup)
|
||||
|
||||
if self.config.aggressive and len(extras) > 0:
|
||||
match = min(filter(lambda p: p.selected == extras[0], partitions), key=Partition.delta, default=None)
|
||||
if match is not None:
|
||||
return match, extras[0]
|
||||
return "default", extras[0]
|
||||
|
||||
if len(sorted) <= self.count and not self.config.aggressive:
|
||||
return "default", None
|
||||
elif (self.config.aggressive or len(sorted) > self.count) and len(extras) > 0:
|
||||
return "default", min(extras, default=None, key=lambda s: s.date())
|
||||
elif len(sorted) > self.count:
|
||||
# no non-keep is invalid, so delete the oldest keeper
|
||||
return "default", min(keepers, default=None, key=lambda s: s.date())
|
||||
return None, None
|
||||
|
||||
def handleNaming(self, backups: Sequence[Backup]) -> None:
|
||||
sorted = list(backups)
|
||||
sorted.sort(key=lambda s: s.date())
|
||||
for backup in sorted:
|
||||
backup.setStatusDetail(None)
|
||||
# Ignored snapshots should have their label cleared in case
|
||||
# it was added previosuly, but should not get new labels
|
||||
unignored = list(filter(lambda s: not s.ignore(), sorted))
|
||||
|
||||
if len(unignored) == 0:
|
||||
return
|
||||
for part in self._buildPartitions(unignored):
|
||||
if part.selected is not None:
|
||||
if part.selected.getStatusDetail() is None:
|
||||
part.selected.setStatusDetail([])
|
||||
part.selected.getStatusDetail().append(part.details)
|
||||
|
||||
def day(self, utc_datetime: datetime, add_days=0):
|
||||
local = self.time.toLocal(utc_datetime)
|
||||
|
||||
local_date = date.fromordinal(date(local.year, local.month, local.day).toordinal() + add_days)
|
||||
return self.time.localize(datetime(local_date.year, local_date.month, local_date.day, 0, 0))
|
||||
@@ -0,0 +1,356 @@
|
||||
from asyncio import CancelledError, Task, create_task, wait, Event
|
||||
from datetime import timedelta
|
||||
from threading import Lock
|
||||
from typing import Dict, List
|
||||
|
||||
from injector import inject, singleton
|
||||
|
||||
from backup.config import Config, Setting, CreateOptions, DurationParser
|
||||
from backup.exceptions import (KnownError, LogicError, NoBackup, PleaseWait,
|
||||
UserCancelledError)
|
||||
from backup.util import GlobalInfo, Backoff, Estimator
|
||||
from backup.time import Time
|
||||
from backup.worker import Trigger
|
||||
from backup.logger import getLogger
|
||||
from backup.creds.creds import Creds
|
||||
from .precache import Precache
|
||||
from .model import BackupSource, Model
|
||||
from .backups import AbstractBackup, Backup, SOURCE_HA
|
||||
from random import Random
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@singleton
|
||||
class Coordinator(Trigger):
|
||||
@inject
|
||||
def __init__(self, model: Model, time: Time, config: Config, global_info: GlobalInfo, estimator: Estimator):
|
||||
super().__init__()
|
||||
self._model = model
|
||||
self._precache: Precache = None
|
||||
self._time = time
|
||||
self._config = config
|
||||
self._lock: Lock = Lock()
|
||||
self._global_info: GlobalInfo = global_info
|
||||
self._sources: Dict[str, BackupSource] = {
|
||||
self._model.source.name(): self._model.source,
|
||||
self._model.dest.name(): self._model.dest
|
||||
}
|
||||
self._backoff = Backoff(initial=0, base=10, max=config.get(Setting.MAX_BACKOFF_SECONDS))
|
||||
self._estimator = estimator
|
||||
self._busy = False
|
||||
self._sync_task: Task = None
|
||||
self._sync_start = Event()
|
||||
self._sync_wait = Event()
|
||||
self._sync_wait.set()
|
||||
self._random = Random()
|
||||
self._random.seed()
|
||||
self._next_sync_offset = self._random.random()
|
||||
self._global_info.triggerBackupCooldown(timedelta(minutes=self._config.get(Setting.BACKUP_STARTUP_DELAY_MINUTES)))
|
||||
self.trigger()
|
||||
|
||||
def saveCreds(self, creds: Creds):
|
||||
if not self._model.dest.enabled():
|
||||
# Since this is the first time saving credentials (eg the addon was just enabled). Hold off on
|
||||
# automatic backups for a few minutes to give the user a little while to figure out whats going on.
|
||||
self._global_info.triggerBackupCooldown(timedelta(minutes=self._config.get(Setting.BACKUP_STARTUP_DELAY_MINUTES)))
|
||||
|
||||
self._model.dest.saveCreds(creds)
|
||||
self._global_info.credsSaved()
|
||||
|
||||
def setPrecache(self, precache: Precache):
|
||||
self._precache = precache
|
||||
|
||||
def name(self):
|
||||
return "Coordinator"
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return self._model.enabled()
|
||||
|
||||
def isWaitingForStartup(self):
|
||||
return self._model.waiting_for_startup
|
||||
|
||||
def ignoreStartupDelay(self):
|
||||
self._model.ignore_startup_delay = True
|
||||
|
||||
async def check(self) -> bool:
|
||||
if self._time.now() >= self.nextSyncAttempt():
|
||||
self.reset()
|
||||
return True
|
||||
else:
|
||||
return await super().check()
|
||||
|
||||
async def sync(self):
|
||||
await self._withSoftLock(lambda: self._sync_wrapper())
|
||||
|
||||
def isSyncing(self):
|
||||
task = self._sync_task
|
||||
return task is not None and not task.done()
|
||||
|
||||
def isWorkingThroughUpload(self):
|
||||
return self.isSyncing() and self._model.isWorkingThroughUpload()
|
||||
|
||||
async def waitForSyncToFinish(self):
|
||||
task = self._sync_task
|
||||
if task is not None:
|
||||
await task
|
||||
|
||||
async def cancel(self):
|
||||
task = self._sync_task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
self.clearCaches()
|
||||
await wait([task])
|
||||
|
||||
def nextSyncAttempt(self):
|
||||
if self._global_info._last_error is not None:
|
||||
# we had an error last
|
||||
failure = self._global_info._last_failure_time
|
||||
if failure is None:
|
||||
return self._time.now() - timedelta(minutes=1)
|
||||
return failure + timedelta(seconds=self._backoff.peek())
|
||||
else:
|
||||
scheduled = self._global_info._last_success
|
||||
if scheduled is None:
|
||||
scheduled = self._time.now() - timedelta(minutes=1)
|
||||
else:
|
||||
scheduled += timedelta(seconds=self.nextSyncCheckOffset())
|
||||
next_backup = self.nextBackupTime()
|
||||
if next_backup is None:
|
||||
return scheduled
|
||||
else:
|
||||
return min(self.nextBackupTime(), scheduled)
|
||||
|
||||
def nextSyncCheckOffset(self):
|
||||
"""Determines how long we shoudl wait from the last check the refresh the cache of backups from Google Drive and Home Assistant"""
|
||||
# If we always sync MAX_SYNC_INTERVAL_SECONDS secodns after the last
|
||||
# check, then the addon in aggregate puts a really high strain on google
|
||||
# on every hour and the addon's auth servers need to be provisioned for
|
||||
# a big peak, which is epxensive. Instead we add some randomness to the time interval.
|
||||
randomness_max = self._config.get(Setting.MAX_SYNC_INTERVAL_SECONDS) * self._config.get(Setting.DEFAULT_SYNC_INTERVAL_VARIATION)
|
||||
non_randomness = self._config.get(Setting.MAX_SYNC_INTERVAL_SECONDS) - randomness_max
|
||||
|
||||
# The offset should be stable between syncs, which gets controlled by updating _next_sync_offset on each good sync
|
||||
return self._next_sync_offset * randomness_max + non_randomness
|
||||
|
||||
def nextBackupTime(self, include_pending=True):
|
||||
return self._buildModel().nextBackup(self._time.now(), include_pending)
|
||||
|
||||
def buildBackupMetrics(self):
|
||||
info = {}
|
||||
for source in self._sources:
|
||||
source_class = self._sources[source]
|
||||
source_info = {
|
||||
'backups': 0,
|
||||
'retained': 0,
|
||||
'deletable': 0,
|
||||
'name': source,
|
||||
'title': source_class.title(),
|
||||
'latest': None,
|
||||
'max': source_class.maxCount(),
|
||||
'enabled': source_class.enabled(),
|
||||
'icon': source_class.icon(),
|
||||
'ignored': 0,
|
||||
'detail': source_class.detail()
|
||||
}
|
||||
size = 0
|
||||
ignored_size = 0
|
||||
latest = None
|
||||
for backup in self.backups():
|
||||
data: AbstractBackup = backup.getSource(source)
|
||||
if data is None:
|
||||
continue
|
||||
if data.ignore() and backup.ignore():
|
||||
source_info['ignored'] += 1
|
||||
if backup.ignore():
|
||||
ignored_size += backup.size()
|
||||
continue
|
||||
source_info['backups'] += 1
|
||||
if data.retained():
|
||||
source_info['retained'] += 1
|
||||
else:
|
||||
source_info['deletable'] += 1
|
||||
if latest is None or data.date() > latest:
|
||||
latest = data.date()
|
||||
size += int(data.sizeInt())
|
||||
if latest is not None:
|
||||
source_info['latest'] = self._time.asRfc3339String(latest)
|
||||
source_info['size'] = Estimator.asSizeString(size)
|
||||
source_info['ignored_size'] = Estimator.asSizeString(ignored_size)
|
||||
free_space = source_class.freeSpace()
|
||||
if free_space is not None and source_class.needsSpaceCheck:
|
||||
source_info['free_space'] = Estimator.asSizeString(free_space)
|
||||
info[source] = source_info
|
||||
return info
|
||||
|
||||
async def _sync_wrapper(self):
|
||||
self._sync_task = create_task(
|
||||
self._sync(), name="Internal sync worker")
|
||||
await wait([self._sync_task])
|
||||
|
||||
async def _sync(self):
|
||||
try:
|
||||
self._sync_start.set()
|
||||
await self._sync_wait.wait()
|
||||
logger.info("Syncing Backups")
|
||||
self._global_info.sync()
|
||||
self._estimator.refresh()
|
||||
await self._buildModel().sync(self._time.now())
|
||||
self._next_sync_offset = self._random.random()
|
||||
self._global_info.success()
|
||||
self._backoff.reset()
|
||||
self._global_info.setSkipSpaceCheckOnce(False)
|
||||
except BaseException as e:
|
||||
self.handleError(e)
|
||||
finally:
|
||||
if self._precache:
|
||||
# Any sync should invalidate the precache regardless of the outcome
|
||||
# so the next sync uses fresh data
|
||||
self.clearCaches()
|
||||
self._updateFreshness()
|
||||
|
||||
def handleError(self, e):
|
||||
if isinstance(e, CancelledError):
|
||||
e = UserCancelledError()
|
||||
if isinstance(e, KnownError):
|
||||
known: KnownError = e
|
||||
logger.error(known.message())
|
||||
if known.retrySoon():
|
||||
self._backoff.backoff(e)
|
||||
else:
|
||||
self._backoff.maxOut()
|
||||
else:
|
||||
logger.printException(e)
|
||||
self._backoff.backoff(e)
|
||||
self._global_info.failed(e)
|
||||
|
||||
text = DurationParser().format(timedelta(seconds=self._backoff.peek()))
|
||||
logger.info("I'll try again in {0}".format(text))
|
||||
|
||||
def backups(self) -> List[Backup]:
|
||||
ret = list(self._model.backups.values())
|
||||
ret.sort(key=lambda s: s.date())
|
||||
return ret
|
||||
|
||||
async def uploadBackups(self, slug):
|
||||
await self._withSoftLock(lambda: self._uploadBackup(slug))
|
||||
|
||||
async def _uploadBackup(self, slug):
|
||||
self.clearCaches()
|
||||
backup = self._ensureBackup(self._model.dest.name(), slug)
|
||||
backup_dest = backup.getSource(self._model.dest.name())
|
||||
backup_source = backup.getSource(self._model.source.name())
|
||||
if backup_source:
|
||||
raise LogicError("This backup already exists in Home Assistant")
|
||||
if not backup_dest:
|
||||
# Unreachable?
|
||||
raise LogicError("This backup isn't in Google Drive")
|
||||
created = await self._model.source.save(backup, await self._model.dest.read(backup))
|
||||
backup.addSource(created)
|
||||
self._updateFreshness()
|
||||
|
||||
async def startBackup(self, options: CreateOptions):
|
||||
return await self._withSoftLock(lambda: self._startBackup(options))
|
||||
|
||||
async def _startBackup(self, options: CreateOptions):
|
||||
self.clearCaches()
|
||||
model = self._buildModel()
|
||||
self._estimator.refresh()
|
||||
if model.source.needsSpaceCheck:
|
||||
self._estimator.checkSpace(self.backups())
|
||||
created = await self._buildModel().source.create(options)
|
||||
backup = Backup(created)
|
||||
self._model.backups[backup.slug()] = backup
|
||||
self._updateFreshness()
|
||||
self._estimator.refresh()
|
||||
return backup
|
||||
|
||||
def getBackup(self, slug):
|
||||
return self._ensureBackup(None, slug)
|
||||
|
||||
async def download(self, slug):
|
||||
self.clearCaches()
|
||||
backup = self._ensureBackup(None, slug)
|
||||
for source in self._sources.values():
|
||||
if not source.enabled():
|
||||
continue
|
||||
if backup.getSource(source.name()):
|
||||
return await source.read(backup)
|
||||
raise NoBackup()
|
||||
|
||||
async def retain(self, sources: Dict[str, bool], slug: str):
|
||||
self.clearCaches()
|
||||
for source in sources:
|
||||
backup = self._ensureBackup(source, slug)
|
||||
await self._ensureSource(source).retain(backup, sources[source])
|
||||
self._updateFreshness()
|
||||
|
||||
async def note(self, note: str, slug: str):
|
||||
self.clearCaches()
|
||||
backup = self._ensureBackup(None, slug)
|
||||
for source in backup.sources.keys():
|
||||
await self._ensureSource(source).note(backup, note)
|
||||
|
||||
async def delete(self, sources, slug):
|
||||
await self._withSoftLock(lambda: self._delete(sources, slug))
|
||||
|
||||
async def ignore(self, slug: str, ignore: bool):
|
||||
await self._withSoftLock(lambda: self._ignore(slug, ignore))
|
||||
|
||||
async def _delete(self, sources, slug):
|
||||
self.clearCaches()
|
||||
for source in sources:
|
||||
backup = self._ensureBackup(source, slug)
|
||||
await self._ensureSource(source).delete(backup)
|
||||
if backup.isDeleted():
|
||||
del self._model.backups[slug]
|
||||
self._updateFreshness()
|
||||
|
||||
async def _ignore(self, slug: str, ignore: bool):
|
||||
self.clearCaches()
|
||||
backup = self._ensureBackup(SOURCE_HA, slug)
|
||||
await self._ensureSource(SOURCE_HA).ignore(backup, ignore)
|
||||
|
||||
def _ensureBackup(self, source: str = None, slug=None) -> Backup:
|
||||
backup = self._buildModel().backups.get(slug)
|
||||
if not backup:
|
||||
raise NoBackup()
|
||||
if not source:
|
||||
return backup
|
||||
if not source:
|
||||
return backup
|
||||
if not backup.getSource(source):
|
||||
raise NoBackup()
|
||||
return backup
|
||||
|
||||
def _ensureSource(self, source):
|
||||
ret = self._sources.get(source)
|
||||
if ret and ret.enabled():
|
||||
return ret
|
||||
raise LogicError()
|
||||
|
||||
def _buildModel(self) -> Model:
|
||||
self._model.reinitialize(self._precache)
|
||||
return self._model
|
||||
|
||||
def _updateFreshness(self):
|
||||
purges = self._buildModel().getNextPurges()
|
||||
for backup in self._model.backups.values():
|
||||
for source in purges:
|
||||
if backup.getSource(source):
|
||||
backup.updatePurge(source, backup == purges[source])
|
||||
|
||||
def clearCaches(self):
|
||||
if self._precache:
|
||||
self._precache.clear()
|
||||
|
||||
async def _withSoftLock(self, callable):
|
||||
with self._lock:
|
||||
if self._busy:
|
||||
raise PleaseWait()
|
||||
self._busy = True
|
||||
try:
|
||||
return await callable()
|
||||
finally:
|
||||
with self._lock:
|
||||
self._busy = False
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
from .coordinator import Coordinator
|
||||
from backup.worker import Worker
|
||||
from injector import inject, singleton
|
||||
from backup.time import Time
|
||||
from backup.logger import getLogger
|
||||
from backup.config import Config, Setting
|
||||
from .model import BackupDestination
|
||||
from .precache import Precache
|
||||
from random import Random
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict
|
||||
from logging import DEBUG
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheItem:
|
||||
"""Class for keeping track of an item in inventory."""
|
||||
valid_until: datetime
|
||||
data: Any
|
||||
|
||||
|
||||
@singleton
|
||||
class DestinationPrecache(Worker, Precache):
|
||||
@inject
|
||||
def __init__(self, coord: Coordinator, time: Time, dest: BackupDestination, config: Config):
|
||||
super().__init__("Traffic Smoothing Cache", self.checkForSmoothing, time, 60)
|
||||
self._config = config
|
||||
self._coord = coord
|
||||
self._dest = dest
|
||||
self._offset = Random().random()
|
||||
self._cache: Dict[str, CacheItem] = {}
|
||||
self._last_error: datetime = None
|
||||
|
||||
async def checkForSmoothing(self):
|
||||
if self._config.get(Setting.CACHE_WARMUP_MAX_SECONDS) == 0:
|
||||
# disable cache warmup
|
||||
return
|
||||
try:
|
||||
self._coord.setPrecache(self)
|
||||
nextSync = self._coord.nextSyncAttempt()
|
||||
now = self._time.now()
|
||||
if nextSync <= now:
|
||||
# No reason to warm the cache if we should sync right now anyway
|
||||
return
|
||||
if self.cached(self._dest.name(), now):
|
||||
# A value is already cached, so don't do anything
|
||||
return
|
||||
if now >= self.getNextWarmDate():
|
||||
# Warm the cache
|
||||
logger.debug("Preemptively retrieving and caching info from the backup destination to avoid peak demand")
|
||||
data = await self._dest.get()
|
||||
validity = nextSync + timedelta(minutes=1)
|
||||
self._cache[self._dest.name()] = CacheItem(validity, data)
|
||||
self._offset = Random().random()
|
||||
except Exception as e:
|
||||
# Any error should make us avoid precaching for a solid day.
|
||||
logger.debug("Unable to precache data from backup destination")
|
||||
logger.printException(e, level=DEBUG)
|
||||
self._offset = Random().random()
|
||||
if self._config.get(Setting.CACHE_WARMUP_ERROR_TIMEOUT_SECONDS) != 0:
|
||||
self._last_error = self._time.now()
|
||||
|
||||
def getNextWarmDate(self):
|
||||
warm_date = self._coord.nextSyncAttempt() - timedelta(seconds=self._config.get(Setting.CACHE_WARMUP_MAX_SECONDS) * self._offset)
|
||||
if self._last_error:
|
||||
return max(warm_date, self._last_error + timedelta(self._config.get(Setting.CACHE_WARMUP_ERROR_TIMEOUT_SECONDS)))
|
||||
return warm_date
|
||||
|
||||
def cached(self, source: str, date: datetime) -> Any:
|
||||
cached = self._cache.get(source)
|
||||
if cached and cached.valid_until >= date:
|
||||
return cached.data
|
||||
return None
|
||||
|
||||
def clear(self):
|
||||
"""Clears any precached data"""
|
||||
self._cache = {}
|
||||
self._offset = Random().random()
|
||||
@@ -0,0 +1,74 @@
|
||||
from .backups import AbstractBackup
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..const import SOURCE_GOOGLE_DRIVE, NECESSARY_PROP_KEY_SLUG, NECESSARY_PROP_KEY_DATE, NECESSARY_PROP_KEY_NAME, PROP_NOTE
|
||||
from ..exceptions import ensureKey
|
||||
from ..config import BoolValidator
|
||||
from ..time import Time
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
PROP_TYPE = "type"
|
||||
PROP_VERSION = "version"
|
||||
PROP_PROTECTED = "protected"
|
||||
PROP_RETAINED = "retained"
|
||||
DRIVE_KEY_TEXT = "Google Drive's backup metadata"
|
||||
|
||||
|
||||
class DriveBackup(AbstractBackup):
|
||||
|
||||
"""
|
||||
Represents a Home Assistant backup stored on Google Drive
|
||||
"""
|
||||
|
||||
def __init__(self, data: Dict[Any, Any]):
|
||||
props = ensureKey('appProperties', data, DRIVE_KEY_TEXT)
|
||||
retained = BoolValidator.strToBool(props.get(PROP_RETAINED, "False"))
|
||||
if NECESSARY_PROP_KEY_NAME in props:
|
||||
backup_name = ensureKey(NECESSARY_PROP_KEY_NAME, props, DRIVE_KEY_TEXT)
|
||||
else:
|
||||
backup_name = data['name'].replace(".tar", "")
|
||||
super().__init__(
|
||||
name=backup_name,
|
||||
slug=ensureKey(NECESSARY_PROP_KEY_SLUG, props, DRIVE_KEY_TEXT),
|
||||
date=Time.parse(
|
||||
ensureKey(NECESSARY_PROP_KEY_DATE, props, DRIVE_KEY_TEXT)),
|
||||
size=int(ensureKey("size", data, DRIVE_KEY_TEXT)),
|
||||
source=SOURCE_GOOGLE_DRIVE,
|
||||
backupType=props.get(PROP_TYPE, "?"),
|
||||
version=props.get(PROP_VERSION, None),
|
||||
protected=BoolValidator.strToBool(props.get(PROP_PROTECTED, "?")),
|
||||
retained=retained,
|
||||
uploadable=False,
|
||||
details=None,
|
||||
note=props.get(PROP_NOTE, None),
|
||||
pending=False)
|
||||
self._drive_data = data
|
||||
self._id = ensureKey('id', data, DRIVE_KEY_TEXT)
|
||||
|
||||
def id(self) -> str:
|
||||
return self._id
|
||||
|
||||
def canDeleteDirectly(self) -> str:
|
||||
caps = self._drive_data.get("capabilities", {})
|
||||
if caps.get('canDelete', False):
|
||||
return True
|
||||
|
||||
# check if the item is in a shared drive
|
||||
sharedId = self._drive_data.get("driveId")
|
||||
if sharedId and len(sharedId) > 0 and caps.get("canTrash", False):
|
||||
# Its in a shared drive and trashable, so trash won't exhaust quota
|
||||
return False
|
||||
|
||||
# We aren't certain we can trash or delete, so just make a try at deleting.
|
||||
return True
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "<Drive: {0} Name: {1} Id: {2}>".format(self.slug(), self.name(), self.id())
|
||||
|
||||
def __format__(self, format_spec: str) -> str:
|
||||
return self.__str__()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
@@ -0,0 +1,26 @@
|
||||
from .backups import Backup
|
||||
from .dummybackupsource import DummyBackupSource
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class DummyBackup(Backup):
|
||||
def __init__(self, name, date, source, slug, size=0, ignore=False, note=None):
|
||||
super().__init__(None)
|
||||
self._size = size
|
||||
self._ignore = ignore
|
||||
self._note = note
|
||||
self.addSource(DummyBackupSource(name, date, source, slug))
|
||||
|
||||
def size(self):
|
||||
return self._size
|
||||
|
||||
def ignore(self):
|
||||
return self._ignore
|
||||
|
||||
def note(self):
|
||||
if self._note is not None:
|
||||
return self._note
|
||||
else:
|
||||
return super().note()
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
from .backups import AbstractBackup
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class DummyBackupSource(AbstractBackup):
|
||||
def __init__(self, name, date, source, slug, retain=False):
|
||||
super().__init__(
|
||||
name=name,
|
||||
slug=slug,
|
||||
date=date,
|
||||
size=0,
|
||||
source=source,
|
||||
backupType="dummy",
|
||||
version="dummy_version",
|
||||
protected=True,
|
||||
retained=retain,
|
||||
uploadable=True,
|
||||
details={})
|
||||
@@ -0,0 +1,72 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from backup.const import SOURCE_HA
|
||||
from backup.exceptions import ensureKey
|
||||
from backup.time import Time
|
||||
from .backups import AbstractBackup
|
||||
from backup.logger import getLogger
|
||||
from backup.util import DataCache, KEY_I_MADE_THIS, KEY_IGNORE, KEY_NOTE
|
||||
from backup.config import Config, Setting
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
HA_KEY_TEXT = "Home Assistant's backup metadata"
|
||||
|
||||
|
||||
class HABackup(AbstractBackup):
|
||||
"""
|
||||
Represents a Home Assistant backup stored locally in Home Assistant
|
||||
"""
|
||||
|
||||
def __init__(self, data: Dict[str, Any], data_cache: DataCache, config: Config, retained=False):
|
||||
super().__init__(
|
||||
name=ensureKey('name', data, HA_KEY_TEXT),
|
||||
slug=ensureKey('slug', data, HA_KEY_TEXT),
|
||||
date=Time.parse(ensureKey('date', data, HA_KEY_TEXT)),
|
||||
size=float(ensureKey("size", data, HA_KEY_TEXT)) * 1024 * 1024,
|
||||
source=SOURCE_HA,
|
||||
backupType=ensureKey('type', data, HA_KEY_TEXT),
|
||||
version=ensureKey('homeassistant', data, HA_KEY_TEXT),
|
||||
protected=ensureKey('protected', data, HA_KEY_TEXT),
|
||||
retained=retained,
|
||||
uploadable=True,
|
||||
details=data,
|
||||
pending=False)
|
||||
self._data_cache = data_cache
|
||||
self._config = config
|
||||
|
||||
def madeByTheAddon(self):
|
||||
return self._data_cache.backup(self.slug()).get(KEY_I_MADE_THIS, False)
|
||||
|
||||
def note(self):
|
||||
parent = super().note()
|
||||
if parent is None:
|
||||
return self._data_cache.backup(self.slug()).get(KEY_NOTE, None)
|
||||
else:
|
||||
return parent
|
||||
|
||||
def ignore(self):
|
||||
override = self._data_cache.backup(self.slug()).get(KEY_IGNORE, None)
|
||||
if override is not None:
|
||||
return override
|
||||
if self.madeByTheAddon():
|
||||
return False
|
||||
if self._config.get(Setting.IGNORE_OTHER_BACKUPS):
|
||||
return True
|
||||
archive_count = len(self.details().get("addons", [])) + len(self.details().get("folders", []))
|
||||
if self.details().get("homeassistant", None) is not None:
|
||||
# Supervisor backup query API doesn't quite match the create API, if the HA config folder
|
||||
# is present in a backup then the Home Assistant version is present in its details
|
||||
archive_count += 1
|
||||
if archive_count == 1 and self._config.get(Setting.IGNORE_UPGRADE_BACKUPS):
|
||||
return True
|
||||
return super().ignore()
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "<HA: {0} Name: {1} {2}>".format(self.slug(), self.name(), self.date().isoformat())
|
||||
|
||||
def __format__(self, format_spec: str) -> str:
|
||||
return self.__str__()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
@@ -0,0 +1,402 @@
|
||||
from datetime import datetime, timedelta, date
|
||||
from io import IOBase
|
||||
from typing import Dict, Generic, List, Optional, Tuple, TypeVar
|
||||
|
||||
from injector import inject, singleton
|
||||
|
||||
from .backupscheme import GenerationalScheme, OldestScheme, DeleteAfterUploadScheme
|
||||
from backup.config import Config, Setting, CreateOptions
|
||||
from backup.exceptions import DeleteMutlipleBackupsError, SimulatedError
|
||||
from backup.util import GlobalInfo, Estimator, DataCache
|
||||
from .backups import AbstractBackup, Backup
|
||||
from .dummybackup import DummyBackup
|
||||
from .precache import Precache
|
||||
from backup.time import Time
|
||||
from backup.worker import Trigger
|
||||
from backup.logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class BackupSource(Trigger, Generic[T]):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
pass
|
||||
|
||||
def name(self) -> str:
|
||||
return "Unnamed"
|
||||
|
||||
def title(self) -> str:
|
||||
return "Default"
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return True
|
||||
|
||||
def needsConfiguration(self) -> bool:
|
||||
return not self.enabled()
|
||||
|
||||
def upload(self) -> bool:
|
||||
return True
|
||||
|
||||
def icon(self) -> str:
|
||||
return "sd_card"
|
||||
|
||||
def freeSpace(self):
|
||||
return None
|
||||
|
||||
@property
|
||||
def needsSpaceCheck(self):
|
||||
return True
|
||||
|
||||
async def create(self, options: CreateOptions) -> T:
|
||||
pass
|
||||
|
||||
async def get(self) -> Dict[str, T]:
|
||||
pass
|
||||
|
||||
async def delete(self, backup: T):
|
||||
pass
|
||||
|
||||
async def ignore(self, backup: T, ignore: bool):
|
||||
pass
|
||||
|
||||
async def save(self, backup: AbstractBackup, bytes: IOBase) -> T:
|
||||
pass
|
||||
|
||||
async def read(self, backup: T) -> IOBase:
|
||||
pass
|
||||
|
||||
async def retain(self, backup: T, retain: bool) -> None:
|
||||
pass
|
||||
|
||||
async def note(self, backup, note: str) -> None:
|
||||
pass
|
||||
|
||||
def maxCount(self) -> None:
|
||||
return 0
|
||||
|
||||
def postSync(self) -> None:
|
||||
return
|
||||
|
||||
def detail(self) -> str:
|
||||
return ""
|
||||
|
||||
def isDestination(self) -> bool:
|
||||
return False
|
||||
|
||||
# Gets called after reading state but before any changes are made
|
||||
# to check for additional errors.
|
||||
def checkBeforeChanges(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class BackupDestination(BackupSource):
|
||||
def isWorking(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def might_be_oob_creds(self) -> bool:
|
||||
return False
|
||||
|
||||
def isDestination(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@singleton
|
||||
class Model():
|
||||
@inject
|
||||
def __init__(self, config: Config, time: Time, source: BackupSource, dest: BackupDestination, info: GlobalInfo, estimator: Estimator, data_cache: DataCache):
|
||||
self.config: Config = config
|
||||
self.time = time
|
||||
self.precache: Precache = None
|
||||
self.source: BackupSource = source
|
||||
self.dest: BackupDestination = dest
|
||||
self.reinitialize()
|
||||
self.backups: Dict[str, Backup] = {}
|
||||
self.firstSync = True
|
||||
self.info = info
|
||||
self.simulate_error = None
|
||||
self.estimator = estimator
|
||||
self.waiting_for_startup = False
|
||||
self.ignore_startup_delay = False
|
||||
self._data_cache = data_cache
|
||||
|
||||
def enabled(self):
|
||||
if self.source.needsConfiguration():
|
||||
return False
|
||||
if self.dest.needsConfiguration():
|
||||
return False
|
||||
return True
|
||||
|
||||
def allSources(self):
|
||||
return [self.source, self.dest]
|
||||
|
||||
def reinitialize(self, precache: Precache = None):
|
||||
self.precache = precache
|
||||
self._time_of_day: Optional[Tuple[int, int]] = self._parseTimeOfDay()
|
||||
|
||||
# SOMEDAY: this should be cached in config and regenerated on config updates, not here
|
||||
self.generational_config = self.config.getGenerationalConfig()
|
||||
|
||||
def getTimeOfDay(self):
|
||||
return self._time_of_day
|
||||
|
||||
def _nextBackup(self, now: datetime, last_backup: Optional[datetime]) -> Optional[datetime]:
|
||||
timeofDay = self.getTimeOfDay()
|
||||
if self.config.get(Setting.DAYS_BETWEEN_BACKUPS) <= 0:
|
||||
next = None
|
||||
elif self.dest.needsConfiguration():
|
||||
next = None
|
||||
elif not last_backup:
|
||||
# this isn't the cleanest logic, but the idea here is that if there are no backups,
|
||||
# then the backups shoudl be made right when the addon starts up.
|
||||
next = self.info.start_time
|
||||
elif not timeofDay:
|
||||
next = last_backup + timedelta(days=self.config.get(Setting.DAYS_BETWEEN_BACKUPS))
|
||||
else:
|
||||
newest_local: datetime = self.time.toLocal(last_backup)
|
||||
time_that_day_local = self.time.localize(datetime(newest_local.year, newest_local.month, newest_local.day, timeofDay[0], timeofDay[1]))
|
||||
if newest_local < time_that_day_local:
|
||||
# Latest backup is before the backup time for that day
|
||||
next = self.time.toUtc(time_that_day_local)
|
||||
else:
|
||||
# return the next backup after the delta
|
||||
next_date = date.fromordinal(int(date(newest_local.year, newest_local.month, newest_local.day).toordinal() + self.config.get(Setting.DAYS_BETWEEN_BACKUPS)))
|
||||
next_datetime_local = self.time.localize(datetime(next_date.year, next_date.month, next_date.day, timeofDay[0], timeofDay[1]))
|
||||
next = self.time.toUtc(next_datetime_local)
|
||||
|
||||
if next is None:
|
||||
self.waiting_for_startup = False
|
||||
return None
|
||||
|
||||
# Don't backup X minutes after startup, since that can put an unreasonable amount of strain on
|
||||
# the system while booting up.
|
||||
cooldown_minimum = self.info.backupCooldownTime()
|
||||
if next <= now and now < cooldown_minimum and not self.ignore_startup_delay:
|
||||
self.waiting_for_startup = True
|
||||
return cooldown_minimum
|
||||
elif self.ignore_startup_delay:
|
||||
self.waiting_for_startup = False
|
||||
return next
|
||||
elif cooldown_minimum > next:
|
||||
self.waiting_for_startup = cooldown_minimum > now
|
||||
return cooldown_minimum
|
||||
else:
|
||||
self.waiting_for_startup = False
|
||||
return next
|
||||
|
||||
def nextBackup(self, now: datetime, include_pending=True):
|
||||
latest = max(filter(lambda s: not s.ignore() and (not s.isPending() or include_pending), self.backups.values()),
|
||||
default=None, key=lambda s: s.date())
|
||||
if latest:
|
||||
latest = latest.date()
|
||||
return self._nextBackup(now, latest)
|
||||
|
||||
async def sync(self, now: datetime):
|
||||
if self.simulate_error is not None:
|
||||
if self.simulate_error.startswith("test"):
|
||||
raise Exception(self.simulate_error)
|
||||
else:
|
||||
raise SimulatedError(self.simulate_error)
|
||||
await self._syncBackups([self.source, self.dest], now)
|
||||
|
||||
self.source.checkBeforeChanges()
|
||||
self.dest.checkBeforeChanges()
|
||||
|
||||
if not self.dest.needsConfiguration():
|
||||
if self.source.enabled():
|
||||
await self._purge(self.source)
|
||||
if self.dest.enabled():
|
||||
await self._purge(self.dest)
|
||||
|
||||
# Delete any "ignored" backups that have expired
|
||||
if (self.config.get(Setting.IGNORE_OTHER_BACKUPS) or self.config.get(Setting.IGNORE_UPGRADE_BACKUPS)) and self.config.get(Setting.DELETE_IGNORED_AFTER_DAYS) > 0:
|
||||
cutoff = now - timedelta(days=self.config.get(Setting.DELETE_IGNORED_AFTER_DAYS))
|
||||
delete = []
|
||||
for backup in self.backups.values():
|
||||
if backup.ignore() and backup.date() < cutoff:
|
||||
delete.append(backup)
|
||||
for backup in delete:
|
||||
await self.deleteBackup(backup, self.source)
|
||||
|
||||
self._handleBackupDetails()
|
||||
next_backup = self.nextBackup(now)
|
||||
if next_backup and now >= next_backup and self.source.enabled() and not self.dest.needsConfiguration():
|
||||
if self.config.get(Setting.DELETE_BEFORE_NEW_BACKUP):
|
||||
await self._purge(self.source, pre_purge=True)
|
||||
await self.createBackup(CreateOptions(now, self.config.get(Setting.BACKUP_NAME)))
|
||||
await self._purge(self.source)
|
||||
self._handleBackupDetails()
|
||||
|
||||
if self.dest.enabled() and self.dest.upload():
|
||||
# get the backups we should upload
|
||||
uploads = []
|
||||
for backup in self.backups.values():
|
||||
if backup.getSource(self.source.name()) is not None and backup.getSource(self.source.name()).uploadable() and backup.getSource(self.dest.name()) is None and not backup.ignore():
|
||||
uploads.append(backup)
|
||||
uploads.sort(key=lambda s: s.date())
|
||||
uploads.reverse()
|
||||
for upload in uploads:
|
||||
# only upload if doing so won't result in it being deleted next
|
||||
dummy = DummyBackup(
|
||||
"", upload.date(), self.dest.name(), "dummy_slug_name")
|
||||
proposed = list(self.backups.values())
|
||||
proposed.append(dummy)
|
||||
if self._nextPurge(self.dest, proposed)[1] != dummy:
|
||||
if self.config.get(Setting.DELETE_BEFORE_NEW_BACKUP):
|
||||
await self._purge(self.dest, pre_purge=True)
|
||||
upload.addSource(await self.dest.save(upload, await self.source.read(upload)))
|
||||
await self._purge(self.dest)
|
||||
self._handleBackupDetails()
|
||||
else:
|
||||
break
|
||||
if self.config.get(Setting.DELETE_AFTER_UPLOAD):
|
||||
await self._purge(self.source)
|
||||
self._handleBackupDetails()
|
||||
self.source.postSync()
|
||||
self.dest.postSync()
|
||||
self._data_cache.saveIfDirty()
|
||||
|
||||
def isWorkingThroughUpload(self):
|
||||
return self.dest.isWorking()
|
||||
|
||||
async def createBackup(self, options):
|
||||
if not self.source.enabled():
|
||||
return
|
||||
|
||||
self.estimator.refresh()
|
||||
if self.source.needsSpaceCheck:
|
||||
self.estimator.checkSpace(list(self.backups.values()))
|
||||
created = await self.source.create(options)
|
||||
backup = Backup(created)
|
||||
self.backups[backup.slug()] = backup
|
||||
|
||||
async def deleteBackup(self, backup, source):
|
||||
if not backup.getSource(source.name()):
|
||||
return
|
||||
slug = backup.slug()
|
||||
await source.delete(backup)
|
||||
backup.removeSource(source.name())
|
||||
if backup.isDeleted():
|
||||
del self.backups[slug]
|
||||
|
||||
def getNextPurges(self):
|
||||
purges = {}
|
||||
for source in [self.source, self.dest]:
|
||||
purges[source.name()] = self._nextPurge(
|
||||
source, self.backups.values(), findNext=True)[1]
|
||||
return purges
|
||||
|
||||
def _parseTimeOfDay(self) -> Optional[Tuple[int, int]]:
|
||||
from_config = self.config.get(Setting.BACKUP_TIME_OF_DAY)
|
||||
if len(from_config) == 0:
|
||||
return None
|
||||
parts = from_config.split(":")
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
try:
|
||||
hour: int = int(parts[0])
|
||||
minute: int = int(parts[1])
|
||||
if hour < 0 or minute < 0 or hour > 23 or minute > 59:
|
||||
return None
|
||||
return (hour, minute)
|
||||
except ValueError:
|
||||
# Parse error
|
||||
return None
|
||||
|
||||
async def _syncBackups(self, sources: List[BackupSource], now: datetime):
|
||||
for source in sources:
|
||||
if source.enabled():
|
||||
# check if we have the results from this source precached
|
||||
from_source: Dict[str, AbstractBackup] = None
|
||||
if self.precache is not None:
|
||||
from_source = self.precache.cached(source.name(), now)
|
||||
if not from_source:
|
||||
from_source = await source.get()
|
||||
else:
|
||||
from_source: Dict[str, AbstractBackup] = {}
|
||||
for backup in from_source.values():
|
||||
if backup.slug() not in self.backups:
|
||||
self.backups[backup.slug()] = Backup(backup)
|
||||
else:
|
||||
self.backups[backup.slug()].addSource(backup)
|
||||
for backup in list(self.backups.values()):
|
||||
if backup.slug() not in from_source:
|
||||
slug = backup.slug()
|
||||
backup.removeSource(source.name())
|
||||
if backup.isDeleted():
|
||||
del self.backups[slug]
|
||||
self.firstSync = False
|
||||
|
||||
def _buildDeleteScheme(self, source, findNext=False):
|
||||
count = source.maxCount()
|
||||
if findNext:
|
||||
count -= 1
|
||||
if source == self.source and self.config.get(Setting.DELETE_AFTER_UPLOAD):
|
||||
return DeleteAfterUploadScheme(source.name(), [self.dest.name()])
|
||||
elif self.generational_config:
|
||||
return GenerationalScheme(
|
||||
self.time, self.generational_config, count=count)
|
||||
else:
|
||||
return OldestScheme(count=count)
|
||||
|
||||
def _buildNamingScheme(self):
|
||||
source = max(filter(BackupSource.enabled, self.allSources()), key=BackupSource.maxCount)
|
||||
return self._buildDeleteScheme(source)
|
||||
|
||||
def _handleBackupDetails(self):
|
||||
self._buildNamingScheme().handleNaming(self.backups.values())
|
||||
|
||||
def _nextPurge(self, source: BackupSource, backups, findNext=False):
|
||||
"""
|
||||
Given a list of backups, decides if one should be purged.
|
||||
"""
|
||||
if not source.enabled() or len(backups) == 0:
|
||||
return None, None
|
||||
if source.maxCount() == 0 and source.isDestination():
|
||||
# When maxCount is zero for a destination, we should never delete from it.
|
||||
return None, None
|
||||
if source.maxCount() == 0 and not self.config.get(Setting.DELETE_AFTER_UPLOAD):
|
||||
return None, None
|
||||
|
||||
scheme = self._buildDeleteScheme(source, findNext=findNext)
|
||||
consider_purging = []
|
||||
for backup in backups:
|
||||
source_backup = backup.getSource(source.name())
|
||||
if source_backup is not None and source_backup.considerForPurge() and not backup.ignore():
|
||||
consider_purging.append(backup)
|
||||
if len(consider_purging) == 0:
|
||||
return None, None
|
||||
return scheme.getOldest(consider_purging)
|
||||
|
||||
async def _purge(self, source: BackupSource, pre_purge=False):
|
||||
while True:
|
||||
purge = self._getPurgeList(source, pre_purge)
|
||||
|
||||
reasons = set(map(lambda p: p[1], purge))
|
||||
if len(purge) <= 0:
|
||||
return
|
||||
if len(purge) != len(reasons) and (self.config.get(Setting.CONFIRM_MULTIPLE_DELETES) and not self.info.isPermitMultipleDeletes()):
|
||||
raise DeleteMutlipleBackupsError(self._getPurgeStats())
|
||||
await self.deleteBackup(purge[0][0], source)
|
||||
|
||||
def _getPurgeStats(self):
|
||||
ret = {}
|
||||
for source in [self.source, self.dest]:
|
||||
ret[source.name()] = len(self._getPurgeList(source))
|
||||
return ret
|
||||
|
||||
def _getPurgeList(self, source: BackupSource, pre_purge=False):
|
||||
if not source.enabled():
|
||||
return []
|
||||
candidates = list(self.backups.values())
|
||||
purges = []
|
||||
while True:
|
||||
reason, next_purge = self._nextPurge(source, candidates, findNext=pre_purge)
|
||||
if next_purge is None:
|
||||
return purges
|
||||
else:
|
||||
purges.append((next_purge, reason))
|
||||
candidates.remove(next_purge)
|
||||
@@ -0,0 +1,15 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Precache(ABC):
|
||||
@abstractmethod
|
||||
def cached(self, source: str, date: datetime) -> Any:
|
||||
"""For a given source and datetime, returns valid precached results if they're available"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def clear(self):
|
||||
"""Clears any cached results stored"""
|
||||
pass
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
from .model import CreateOptions, BackupDestination
|
||||
from .backups import Backup
|
||||
from .dummybackupsource import DummyBackupSource
|
||||
from typing import Dict
|
||||
from io import IOBase
|
||||
from ..ha import BackupName
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class SimulatedSource(BackupDestination):
|
||||
def __init__(self, name, is_destination=False):
|
||||
self._name = name
|
||||
self.current: Dict[str, DummyBackupSource] = {}
|
||||
self.saved = []
|
||||
self.deleted = []
|
||||
self.created = []
|
||||
self._enabled = True
|
||||
self._upload = True
|
||||
self.index = 0
|
||||
self.max = 0
|
||||
self.backup_name = BackupName()
|
||||
self.host_info = {}
|
||||
self.backup_type = "Full"
|
||||
self.working = False
|
||||
self.needConfig = None
|
||||
self.is_destination = is_destination
|
||||
|
||||
def isDestination(self):
|
||||
return self.is_destination
|
||||
|
||||
def setEnabled(self, value):
|
||||
self._enabled = value
|
||||
return self
|
||||
|
||||
def needsConfiguration(self) -> bool:
|
||||
if self.needConfig is not None:
|
||||
return self.needConfig
|
||||
return super().needsConfiguration()
|
||||
|
||||
def setNeedsConfiguration(self, value: bool):
|
||||
self.needConfig = value
|
||||
|
||||
def setUpload(self, value):
|
||||
self._upload = value
|
||||
return self
|
||||
|
||||
def upload(self):
|
||||
return self._upload
|
||||
|
||||
def setMax(self, count):
|
||||
self.max = count
|
||||
return self
|
||||
|
||||
def isWorking(self):
|
||||
return self.working
|
||||
|
||||
def setIsWorking(self, value):
|
||||
self.working = value
|
||||
|
||||
def maxCount(self) -> None:
|
||||
return self.max
|
||||
|
||||
def insert(self, name, date, slug=None, retain=False):
|
||||
if slug is None:
|
||||
slug = name
|
||||
new_backup = DummyBackupSource(
|
||||
name,
|
||||
date,
|
||||
self._name,
|
||||
slug)
|
||||
self.current[new_backup.slug()] = new_backup
|
||||
return new_backup
|
||||
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def nameSetup(self, type, host_info):
|
||||
self.backup_type = type
|
||||
self.host_info = host_info
|
||||
|
||||
async def create(self, options: CreateOptions) -> DummyBackupSource:
|
||||
assert self.enabled
|
||||
new_backup = DummyBackupSource(
|
||||
self.backup_name.resolve(
|
||||
self.backup_type, options.name_template, options.when, self.host_info),
|
||||
options.when,
|
||||
self._name,
|
||||
"{0}slug{1}".format(self._name, self.index))
|
||||
self.index += 1
|
||||
self.current[new_backup.slug()] = new_backup
|
||||
self.created.append(new_backup)
|
||||
return new_backup
|
||||
|
||||
async def get(self) -> Dict[str, DummyBackupSource]:
|
||||
assert self.enabled
|
||||
return self.current
|
||||
|
||||
async def delete(self, backup: Backup):
|
||||
assert self.enabled
|
||||
assert backup.getSource(self._name) is not None
|
||||
assert backup.getSource(self._name).source() is self._name
|
||||
assert backup.slug() in self.current
|
||||
slug = backup.slug()
|
||||
self.deleted.append(backup.getSource(self._name))
|
||||
backup.removeSource(self._name)
|
||||
del self.current[slug]
|
||||
|
||||
async def save(self, backup: Backup, bytes: IOBase = None) -> DummyBackupSource:
|
||||
assert self.enabled
|
||||
assert backup.slug() not in self.current
|
||||
new_backup = DummyBackupSource(
|
||||
backup.name(), backup.date(), self._name, backup.slug())
|
||||
backup.addSource(new_backup)
|
||||
self.current[new_backup.slug()] = new_backup
|
||||
self.saved.append(new_backup)
|
||||
return new_backup
|
||||
|
||||
async def read(self, backup: DummyBackupSource) -> IOBase:
|
||||
assert self.enabled
|
||||
return None
|
||||
|
||||
async def retain(self, backup: DummyBackupSource, retain: bool) -> None:
|
||||
assert self.enabled
|
||||
backup.getSource(self.name()).setRetained(retain)
|
||||
@@ -0,0 +1,36 @@
|
||||
from typing import List
|
||||
|
||||
from injector import inject, singleton
|
||||
|
||||
from .coordinator import Coordinator
|
||||
from backup.time import Time
|
||||
from backup.worker import Worker, Trigger
|
||||
from backup.logger import getLogger
|
||||
from backup.exceptions import PleaseWait
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@singleton
|
||||
class Scyncer(Worker):
|
||||
@inject
|
||||
def __init__(self, time: Time, coord: Coordinator, triggers: List[Trigger]):
|
||||
super().__init__("Sync Worker", self.checkforSync, time, 0.5)
|
||||
self.coord = coord
|
||||
self.triggers: List[Trigger] = triggers
|
||||
self._time = time
|
||||
|
||||
async def checkforSync(self):
|
||||
try:
|
||||
doSync = False
|
||||
for trigger in self.triggers:
|
||||
if await trigger.check():
|
||||
logger.debug("Sync requested by " + str(trigger.name()))
|
||||
doSync = True
|
||||
if doSync:
|
||||
while self.coord.isSyncing():
|
||||
await self._time.sleepAsync(3)
|
||||
await self.coord.sync()
|
||||
except PleaseWait:
|
||||
# Ignore this, since it means a sync already started (unavilable race condition)
|
||||
pass
|
||||
@@ -0,0 +1,87 @@
|
||||
import socket
|
||||
import sys
|
||||
import aiohttp
|
||||
import os
|
||||
from aiohttp import ClientSession
|
||||
from injector import Module, provider, singleton, multiprovider
|
||||
from typing import List
|
||||
|
||||
from backup.config import Config, Startable, Setting
|
||||
from backup.drive import DriveSource
|
||||
from backup.ha import HaSource, HaUpdater, AddonStopper
|
||||
from backup.model import BackupDestination, BackupSource, Scyncer
|
||||
from backup.util import Resolver
|
||||
from backup.model import Coordinator, Precache, DestinationPrecache
|
||||
from backup.worker import Trigger
|
||||
from backup.watcher import Watcher
|
||||
from backup.ui import UiServer, Restarter
|
||||
from backup.logger import getLogger
|
||||
from backup.debug import DebugServer
|
||||
from .debugworker import DebugWorker
|
||||
from .tracing_session import TracingSession
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class BaseModule(Module):
|
||||
'''
|
||||
A module shared between tests and main
|
||||
'''
|
||||
def __init__(self, override_dns=True):
|
||||
self._override_dns = override_dns
|
||||
|
||||
@multiprovider
|
||||
@singleton
|
||||
def getTriggers(self, coord: Coordinator, ha: HaSource, drive: DriveSource, watcher: Watcher, server: UiServer) -> List[Trigger]:
|
||||
return [coord, ha, drive, watcher, server]
|
||||
|
||||
@provider
|
||||
@singleton
|
||||
def getDrive(self, drive: DriveSource) -> BackupDestination:
|
||||
return drive
|
||||
|
||||
@provider
|
||||
@singleton
|
||||
def getHa(self, ha: HaSource) -> BackupSource:
|
||||
return ha
|
||||
|
||||
@provider
|
||||
@singleton
|
||||
def getPrecache(self, cache: DestinationPrecache) -> Precache:
|
||||
return cache
|
||||
|
||||
@multiprovider
|
||||
@singleton
|
||||
def getStartables(self, debug_server: DebugServer, ha_updater: HaUpdater, debugger: DebugWorker, ha_source: HaSource,
|
||||
server: UiServer, restarter: Restarter, syncer: Scyncer, watcher: Watcher, stopper: AddonStopper, precache: Precache) -> List[Startable]:
|
||||
# Order here matters, since its the order in which components of the addon are initialized.
|
||||
return [debug_server, ha_updater, debugger, ha_source, server, restarter, syncer, watcher, stopper, precache]
|
||||
|
||||
@provider
|
||||
@singleton
|
||||
def getSession(self, resolver: Resolver, config: Config) -> ClientSession:
|
||||
conn = None
|
||||
if self._override_dns:
|
||||
conn = aiohttp.TCPConnector(resolver=resolver, family=socket.AF_INET)
|
||||
return TracingSession(config, connector=conn)
|
||||
|
||||
|
||||
class MainModule(Module):
|
||||
@provider
|
||||
@singleton
|
||||
def getConfig(self) -> Config:
|
||||
alt_config = None
|
||||
index = 1
|
||||
for arg in sys.argv[1:]:
|
||||
if arg == "--config":
|
||||
alt_config = sys.argv[index + 1]
|
||||
break
|
||||
index += 1
|
||||
|
||||
if alt_config:
|
||||
config = Config.withFileOverrides(alt_config)
|
||||
elif "PYTEST_CURRENT_TEST" in os.environ:
|
||||
config = Config()
|
||||
else:
|
||||
config = Config.fromFile(Setting.CONFIG_FILE_PATH.default())
|
||||
logger.overrideLevel(config.get(Setting.CONSOLE_LOG_LEVEL), config.get(Setting.LOG_LEVEL))
|
||||
return config
|
||||
@@ -0,0 +1,4 @@
|
||||
# flake8: noqa
|
||||
from .server import Server
|
||||
from .errorstore import ErrorStore
|
||||
from .cloudlogger import CloudLogger
|
||||
@@ -0,0 +1,27 @@
|
||||
import aiorun
|
||||
from .server import Server
|
||||
from backup.config import Config
|
||||
from backup.module import BaseModule
|
||||
from injector import Injector
|
||||
from injector import provider, singleton
|
||||
|
||||
|
||||
class ServerModule(BaseModule):
|
||||
def __init__(self):
|
||||
super().__init__(override_dns=False)
|
||||
|
||||
@provider
|
||||
@singleton
|
||||
def getConfig(self) -> Config:
|
||||
return Config.fromEnvironment()
|
||||
|
||||
|
||||
async def main():
|
||||
module = ServerModule()
|
||||
injector = Injector(module)
|
||||
await injector.get(Server).start()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("Starting")
|
||||
aiorun.run(main())
|
||||
@@ -0,0 +1,28 @@
|
||||
import os
|
||||
import json
|
||||
from backup.logger import getLogger, StandardLogger
|
||||
from injector import inject, singleton
|
||||
from google.cloud import logging
|
||||
from google.auth.exceptions import DefaultCredentialsError
|
||||
|
||||
basic_logger = getLogger(__name__)
|
||||
|
||||
|
||||
@singleton
|
||||
class CloudLogger(StandardLogger):
|
||||
@inject
|
||||
def __init__(self):
|
||||
super().__init__(__name__)
|
||||
self.google_logger = None
|
||||
if os.environ.get('GOOGLE_APPLICATION_CREDENTIALS') is not None:
|
||||
try:
|
||||
google_logger_client = logging.Client()
|
||||
self.googler_logger = google_logger_client.logger("refresh_server")
|
||||
except DefaultCredentialsError:
|
||||
basic_logger.error("Unable to start Google Logger, no default credentials")
|
||||
|
||||
def log_struct(self, data):
|
||||
if self.google_logger is not None:
|
||||
self.google_logger.log_struct(data)
|
||||
else:
|
||||
basic_logger.info(json.dumps(data))
|
||||
@@ -0,0 +1,32 @@
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials
|
||||
from firebase_admin import firestore
|
||||
from datetime import datetime
|
||||
from backup.config import Setting, Config
|
||||
from .cloudlogger import CloudLogger
|
||||
from injector import inject, singleton
|
||||
|
||||
|
||||
@singleton
|
||||
class ErrorStore():
|
||||
@inject
|
||||
def __init__(self, logger: CloudLogger, config: Config):
|
||||
try:
|
||||
cred = credentials.ApplicationDefault()
|
||||
firebase_admin.initialize_app(cred, {
|
||||
'projectId': config.get(Setting.SERVER_PROJECT_ID),
|
||||
})
|
||||
self.db = firestore.client()
|
||||
except Exception as e:
|
||||
logger.log_struct({
|
||||
"error": "unable to initialize firestore, errors will not be logged to firestore. If you are running this on a developer machine, this error is normal.",
|
||||
"exception": str(e)
|
||||
})
|
||||
self.db = None
|
||||
self.last_error = None
|
||||
|
||||
def store(self, error_data):
|
||||
if self.db is not None:
|
||||
doc_ref = self.db.collection(u'error_reports').document(error_data.get('client', "unknown") + "-" + datetime.now().isoformat())
|
||||
doc_ref.set(error_data)
|
||||
self.last_error = error_data
|
||||
@@ -0,0 +1,218 @@
|
||||
import json
|
||||
import aiohttp_jinja2
|
||||
import jinja2
|
||||
import base64
|
||||
from os.path import abspath, join
|
||||
from aiohttp.web import Application, json_response, Request, TCPSite, AppRunner, post, Response, static, get
|
||||
from aiohttp.client_exceptions import ClientResponseError, ClientConnectorError, ServerConnectionError, ServerDisconnectedError, ServerTimeoutError
|
||||
from aiohttp.web_exceptions import HTTPBadRequest, HTTPSeeOther
|
||||
from backup.creds import Exchanger
|
||||
from backup.config import Config, Setting, VERSION
|
||||
from backup.exceptions import GoogleCredentialsExpired, ensureKey, KnownError
|
||||
from injector import ClassAssistedBuilder, inject, singleton
|
||||
from .errorstore import ErrorStore
|
||||
from .cloudlogger import CloudLogger
|
||||
from yarl import URL
|
||||
from backup.config import Version
|
||||
from urllib.parse import unquote
|
||||
|
||||
NEW_AUTH_MINIMUM = Version(0, 101, 3)
|
||||
|
||||
|
||||
@singleton
|
||||
class Server():
|
||||
@inject
|
||||
def __init__(self,
|
||||
config: Config,
|
||||
exchanger_builder: ClassAssistedBuilder[Exchanger],
|
||||
logger: CloudLogger,
|
||||
error_store: ErrorStore):
|
||||
self.exchanger = exchanger_builder.build(
|
||||
client_id=config.get(Setting.DEFAULT_DRIVE_CLIENT_ID),
|
||||
client_secret=config.get(Setting.DEFAULT_DRIVE_CLIENT_SECRET),
|
||||
redirect=URL(config.get(Setting.AUTHORIZATION_HOST)).with_path("/drive/authorize"))
|
||||
self.logger = logger
|
||||
self.config = config
|
||||
self.error_store = error_store
|
||||
|
||||
def base_context(self, request: Request):
|
||||
return {
|
||||
'version': VERSION,
|
||||
'backgroundColor': request.query.get('bg', self.config.get(Setting.BACKGROUND_COLOR)),
|
||||
'accentColor': request.query.get('ac', self.config.get(Setting.ACCENT_COLOR)),
|
||||
'bmc_logo_path': "/static/" + VERSION + "/images/bmc.svg"
|
||||
}
|
||||
|
||||
async def authorize(self, request: Request):
|
||||
if 'redirectbacktoken' in request.query:
|
||||
version = Version.parse(request.query.get('version', "0"))
|
||||
token_url = request.query.get('redirectbacktoken')
|
||||
return_url = request.query.get('return', None)
|
||||
state = {
|
||||
'v': str(version),
|
||||
'token': token_url,
|
||||
'return': return_url,
|
||||
'bg': self.base_context(request).get('backgroundColor'),
|
||||
'ac': self.base_context(request).get('accentColor'),
|
||||
}
|
||||
# Someone is trying to authenticate with the add-on, direct them to the google auth url
|
||||
raise HTTPSeeOther(await self.exchanger.getAuthorizationUrl(json.dumps(state)))
|
||||
elif 'state' in request.query and 'code' in request.query:
|
||||
state = json.loads(unquote(request.query.get('state')))
|
||||
code = request.query.get('code')
|
||||
try:
|
||||
version = Version.parse(state["v"])
|
||||
creds = (await self.exchanger.exchange(code)).serialize(include_secret=False)
|
||||
|
||||
if version < NEW_AUTH_MINIMUM:
|
||||
# Redirect back to the addon, since this is the older addon
|
||||
url = URL(state['token']).with_query({'creds': json.dumps(creds)})
|
||||
raise HTTPSeeOther(url)
|
||||
|
||||
serialized_creds = str(base64.b64encode(json.dumps(creds).encode("utf-8")), "utf-8")
|
||||
url = URL(state['token']).with_query({
|
||||
'creds': serialized_creds,
|
||||
'host': state['return']})
|
||||
context = {
|
||||
**self.base_context(request),
|
||||
'redirect_url': str(url),
|
||||
'credentials_serialized': serialized_creds,
|
||||
}
|
||||
if 'bg' in state:
|
||||
context['backgroundColor'] = state['bg']
|
||||
if 'ac' in state:
|
||||
context['accentColor'] = state['ac']
|
||||
return aiohttp_jinja2.render_template(
|
||||
"authorize.jinja2",
|
||||
request,
|
||||
context)
|
||||
except Exception as e:
|
||||
if isinstance(e, HTTPSeeOther):
|
||||
# expected, pass this thorugh
|
||||
raise
|
||||
self.logError(request, e)
|
||||
content = "The server encountered an error while processing this request: " + str(e) + "<br/>"
|
||||
content += "Please <a href='https://github.com/sabeechen/hassio-google-drive-backup/issues'>file an issue</a> on Home Assistant Google Backup's GitHub page so I'm aware of this problem or attempt authorizing with Google Drive again."
|
||||
return Response(status=500, body=content)
|
||||
else:
|
||||
raise HTTPBadRequest()
|
||||
|
||||
async def error(self, request: Request):
|
||||
try:
|
||||
self.logReport(request, await request.json())
|
||||
except BaseException as e:
|
||||
self.logError(request, e)
|
||||
return Response()
|
||||
|
||||
async def refresh(self, request: Request):
|
||||
try:
|
||||
token = ensureKey('refresh_token', await request.json(), "the request payload")
|
||||
creds = self.exchanger.refreshCredentials(token)
|
||||
new_creds = await self.exchanger.refresh(creds)
|
||||
return json_response(new_creds.serialize(include_secret=False))
|
||||
except ClientResponseError as e:
|
||||
if e.status == 401:
|
||||
return json_response({
|
||||
"error": "expired"
|
||||
}, status=401)
|
||||
else:
|
||||
self.logError(request, e)
|
||||
return json_response({
|
||||
"error": "Google returned HTTP {}".format(e.status)
|
||||
}, status=503)
|
||||
except ClientConnectorError:
|
||||
return json_response({
|
||||
"error": "Couldn't connect to Google's servers"
|
||||
}, status=503)
|
||||
except ServerConnectionError:
|
||||
return json_response({
|
||||
"error": "Couldn't connect to Google's servers"
|
||||
}, status=503)
|
||||
except ServerDisconnectedError:
|
||||
return json_response({
|
||||
"error": "Couldn't connect to Google's servers"
|
||||
}, status=503)
|
||||
except ServerTimeoutError:
|
||||
return json_response({
|
||||
"error": "Google's servers timed out"
|
||||
}, status=503)
|
||||
except GoogleCredentialsExpired:
|
||||
return json_response({
|
||||
"error": "expired"
|
||||
}, status=401)
|
||||
except KnownError as e:
|
||||
return json_response({
|
||||
"error": e.message()
|
||||
}, status=503)
|
||||
except Exception as e:
|
||||
self.logError(request, e)
|
||||
return json_response({
|
||||
"error": str(e)
|
||||
}, status=500)
|
||||
|
||||
@aiohttp_jinja2.template('picker.jinja2')
|
||||
async def picker(self, request: Request):
|
||||
version = Version.parse(request.query.get('version', "0"))
|
||||
bg = request.query.get('bg', self.config.get(Setting.BACKGROUND_COLOR))
|
||||
ac = request.query.get('ac', self.config.get(Setting.ACCENT_COLOR))
|
||||
return {
|
||||
**self.base_context(request),
|
||||
"client_id": self.config.get(Setting.DEFAULT_DRIVE_CLIENT_ID),
|
||||
"developer_key": self.config.get(Setting.DRIVE_PICKER_API_KEY),
|
||||
"app_id": self.config.get(Setting.DEFAULT_DRIVE_CLIENT_ID).split("-")[0],
|
||||
'backgroundColor': bg,
|
||||
'accentColor': ac,
|
||||
"do_redirect": str(version < NEW_AUTH_MINIMUM).lower()
|
||||
}
|
||||
|
||||
@aiohttp_jinja2.template('server-index.jinja2')
|
||||
async def index(self, request: Request):
|
||||
return self.base_context(request)
|
||||
|
||||
async def health(self, request: Request):
|
||||
return json_response({
|
||||
'status': 'ok',
|
||||
'messages': []
|
||||
})
|
||||
|
||||
def buildApp(self, app):
|
||||
path = abspath(join(__file__, "..", "..", "static"))
|
||||
app.add_routes([
|
||||
static("/static/" + VERSION, path, append_version=True),
|
||||
static("/drive/static/" + VERSION, path, append_version=True),
|
||||
get("/drive/picker", self.picker),
|
||||
get("/", self.index),
|
||||
get("/drive/authorize", self.authorize),
|
||||
post("/drive/refresh", self.refresh),
|
||||
post("/logerror", self.error),
|
||||
get("/health", self.health)
|
||||
])
|
||||
aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(path))
|
||||
return app
|
||||
|
||||
async def start(self):
|
||||
runner = AppRunner(self.buildApp(Application()))
|
||||
await runner.setup()
|
||||
site = TCPSite(runner, "0.0.0.0", int(self.config.get(Setting.PORT)))
|
||||
await site.start()
|
||||
self.logger.info("Backup Auth Server Started")
|
||||
|
||||
def logError(self, request: Request, exception: Exception):
|
||||
data = self.getRequestInfo(request)
|
||||
data['exception'] = self.logger.formatException(exception)
|
||||
self.logger.log_struct(data)
|
||||
|
||||
def logReport(self, request, report):
|
||||
data = self.getRequestInfo(request)
|
||||
data['report'] = report
|
||||
self.logger.log_struct(data)
|
||||
self.error_store.store(data)
|
||||
|
||||
def getRequestInfo(self, request: Request):
|
||||
return {
|
||||
'client': request.headers.get('client', "unknown"),
|
||||
'version': request.headers.get('addon_version', "unknown"),
|
||||
'address': request.remote,
|
||||
'url': str(request.url),
|
||||
'length': request.content_length
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user