New Addon
Google BK
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user