Files
admins 702d080a1e New Addon
Google BK
2023-04-23 18:17:03 +07:00

538 lines
21 KiB
Python

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