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,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)