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