New Addon

Google BK
This commit is contained in:
2023-04-23 18:17:03 +07:00
parent 1c7fd2b476
commit 702d080a1e
248 changed files with 72745 additions and 0 deletions
@@ -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,329 @@
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")
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_BACKUP_PATH
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
@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 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,537 @@
import asyncio
import aiohttp
from datetime import timedelta
from io import IOBase
from threading import Lock, Thread
from typing import Dict, List, Optional
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
from ..const import SOURCE_HA
from ..model import BackupSource, AbstractBackup, HABackup, Backup
from ..exceptions import (LogicError, BackupInProgress,
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.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 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: str) -> 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()
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 = {
'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
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