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 .asynchttpgetter import AsyncHttpGetter
from .backoff import Backoff
from .estimator import Estimator
from .globalinfo import GlobalInfo
from .resolver import Resolver
from .rangelookup import RangeLookup
from .data_cache import DataCache, KEY_CREATED, KEY_I_MADE_THIS, KEY_PENDING, KEY_NOTE, KEY_IGNORE, KEY_LAST_SEEN, KEY_NAME, CACHE_EXPIRATION_DAYS, UpgradeFlags
@@ -0,0 +1,241 @@
from datetime import timedelta
import io
from typing import Dict
from aiohttp import ClientSession
from aiohttp.client import ClientResponse, ClientPayloadError, ClientOSError
from asyncio.exceptions import TimeoutError
from collections import deque
from ..exceptions import LogicError, ensureKey
from ..logger import getLogger
from ..time import Time
logger = getLogger(__name__)
CONTENT_LENGTH_HEADER = "content-length"
CONTENT_LENGTH_ERROR = "Content size must be provided if the webserver doesn't provide it"
SERVER_CONTENT_LENGTH_ERROR = "Server returned a content length that didn't match the requested size"
POSITION_ERROR_MESSAGE = "AsyncHttpGetter must also be set up at position 0"
DEFAULT_CHUNK_SIZE = 1024 * 1024
# This class is dumb but it gets around a dumb problem
class Stupid(io.BytesIO):
def __len__(self):
return len(self.getvalue())
class AsyncHttpGetter:
def __init__(self, url, headers: Dict[str, str], session, size: int = None, timeout=None, timeoutFactory=None, otherErrorFactory=None, time: Time = None):
self._url: str = url
# Current position of the stream
self._position: int = 0
# Total size of the stream
self._size: int = size
# Headers that should get sent with every request
self._headers: Dict[str, str] = headers
# Session used to make http requests.
self._session: ClientSession = session
# Most recent session request
self._response: ClientResponse = None
# Where the resposne currently starts
self._responseStart = 0
self._history = deque()
self._time = time
self._startTime = self._time.now()
self.timeoutFactory = timeoutFactory
self.otherErrorFactory = otherErrorFactory
self.timeout = timeout
async def setup(self):
if not self._position == 0:
raise LogicError(POSITION_ERROR_MESSAGE)
await self._startReadRemoteAt(0)
if CONTENT_LENGTH_HEADER in self._response.headers:
self._size = int(ensureKey(
CONTENT_LENGTH_HEADER, self._response.headers, "web server get request's headers"))
elif self._size is None:
raise LogicError(
CONTENT_LENGTH_ERROR)
self._history.append([self._time.now(), 0])
return self._size
def _ensureSetup(self):
if self._size is None:
raise LogicError("AsyncHttpGetter.setup() must be called first")
def size(self) -> int:
self._ensureSetup()
return self._size
def __len__(self):
return self.size()
def position(self, pos=None):
if pos is not None:
self._position = pos
return self._position
async def generator(self, chunk_size):
while True:
chunk = await self.read(chunk_size)
if len(chunk.getbuffer()) == 0:
break
yield chunk.getbuffer()
def progress(self):
if self._size == 0:
return 0
self._ensureSetup()
if self._size == 0:
return 0
return 100 * float(self.position()) / float(self._size)
# return the estimated speed of the tranfser in bytes/second
def speed(self, period: timedelta = timedelta(seconds=10)):
if len(self._history) < 2:
return None
now = self._time.now()
intervals = []
current = self._history[0]
last_speed = 0
for x in range(1, len(self._history)):
next = self._history[x]
seconds = (next[0] - current[0]).total_seconds()
data = next[1] - current[1]
if seconds == 0:
speed = 0 # avoid div by 0
else:
speed = data / seconds
intervals.append([current[0], next[0], speed])
current = next
last_speed = speed
time_since_last = now - current[0]
if time_since_last > period:
# linear decay the "estimated" rate over the period
diff = time_since_last - period
if diff > period:
return 0
else:
return last_speed * (1 - diff.total_seconds() / period.total_seconds())
# behave as though we continued with the pseed form the last interval
intervals.append([current[0], now, last_speed])
# calculate the time-averaged speed over the given period
stop = now
start = now - period
total = 0
minimum_time = now
for interval in intervals:
if start > interval[1]:
continue
if stop < interval[0]:
continue
overlap_start = max(start, interval[0])
overlap_stop = min(stop, interval[1])
if overlap_stop > overlap_start:
total += (overlap_stop - overlap_start).total_seconds() * interval[2]
if overlap_start < minimum_time:
minimum_time = overlap_start
return total / (now - minimum_time).total_seconds()
def startTime(self):
return self._startTime
def __format__(self, format_spec: str) -> str:
return str(int(self.progress()))
async def _startReadRemoteAt(self, where: int):
headers = self._headers.copy()
# request a byte range
if where != 0:
headers['range'] = "bytes=%s-%s" % (self._position, self._size - 1)
if self._response is not None:
self._response.release()
try:
resp = await self._session.get(self._url, headers=headers, timeout=self.timeout)
except TimeoutError:
if self.timeoutFactory is not None:
raise self.timeoutFactory()
raise
except ClientPayloadError:
if self.otherErrorFactory is not None:
raise self.otherErrorFactory()
raise
except ClientOSError:
if self.otherErrorFactory is not None:
raise self.otherErrorFactory()
raise
resp.raise_for_status()
if where == 0 and self._size is not None and CONTENT_LENGTH_HEADER in resp.headers and int(resp.headers[CONTENT_LENGTH_HEADER]) != self._size:
resp.release()
raise LogicError(SERVER_CONTENT_LENGTH_ERROR)
self._response = resp
self._responseStart = where
async def read(self, count=DEFAULT_CHUNK_SIZE):
self._ensureSetup()
ret = Stupid()
if self._size is not None and self._position >= self._size:
return ret
# See if we need to move the stream elsewhere
if self._responseStart != self._position:
# Reset the stream's position
await self._startReadRemoteAt(self._position)
# Limit by how much we can get from the stream
needed = min(count, self.size() - self._position)
# And then get it
try:
data = await self._response.content.readexactly(needed)
except TimeoutError:
if self.timeoutFactory is not None:
raise self.timeoutFactory()
raise
except ClientPayloadError:
if self.otherErrorFactory is not None:
raise self.otherErrorFactory()
raise
except ClientOSError:
if self.otherErrorFactory is not None:
raise self.otherErrorFactory()
raise
ret.write(data)
# Keep track of where we are in the stream
self._responseStart += len(data)
self._position += len(data)
self._history.append([self._time.now(), self._position])
if len(self._history) > 50:
self._history.popleft()
ret.seek(0)
return ret
async def __aenter__(self):
await self.setup()
async def __aexit__(self, type, value, traceback):
if self._response is not None:
self._response.release()
def __aiter__(self):
return self
async def __anext__(self):
val = await self.read()
if len(val) == 0:
raise StopAsyncIteration
return val.getbuffer()
@@ -0,0 +1,42 @@
from ..logger import getLogger
MAX_FACTOR = 20 # avoid weirdness with python's arbitary integer precision
MAX_WAIT = 60 * 60 # 1 hour
logger = getLogger(__name__)
class Backoff():
def __init__(self, initial=None, base=2, exp=2, max=MAX_WAIT, attempts=None):
self._attempts = attempts
self._initial = initial
self._start = base
self._max = max
self._exp = exp
self._counter = 0
def reset(self):
self._counter = 0
def peek(self):
exp = self._counter - 1
if self._counter == 1 and self._initial is not None:
return self._initial
elif self._initial is not None:
exp -= 1
exp = min(exp, MAX_FACTOR)
computed = self._start * pow(self._exp, exp)
if self._max:
computed = min(self._max, computed)
return computed
def backoff(self, error):
if self._attempts and self._counter >= self._attempts:
raise error
self._counter += 1
return self.peek()
def maxOut(self):
self._counter = 100
@@ -0,0 +1,149 @@
from datetime import timedelta
from enum import Enum, unique
from backup.config import Config, Setting, VERSION, Version
from backup.file import JsonFileSaver
from backup.const import NECESSARY_OLD_BACKUP_PLURAL_NAME
from injector import inject, singleton
from ..time import Time
from typing import Dict
import json
import os
KEY_I_MADE_THIS = "i_made_this"
KEY_PENDING = "pending"
KEY_CREATED = "created"
KEY_IGNORE = "ignore"
KEY_LAST_SEEN = "last_seen"
KEY_NAME = "name"
KEY_LAST_VERSION = "last_verison"
KEY_UPGRADES = "upgrades"
KEY_FLAGS = "flags"
KEY_NOTE = "note"
CACHE_EXPIRATION_DAYS = 30
VERSION_DEFUALT_IGNORE_UPGRADES = Version.parse("0.108.2")
@unique
class UpgradeFlags(Enum):
NOTIFIED_ABOUT_BACKUP_RENAME = "notified_backup_rename"
NOTIFIED_ABOUT_IGNORED_BACKUPS = "notified_ignored_backups"
NOTIFIED_ABOUT_OOB_FLOW = "notified_about_oob_flow"
TESTING_FLAG = "testing_flag"
@singleton
class DataCache:
@inject
def __init__(self, config: Config, time: Time):
self._config = config
self._data = {}
self._dirty = {}
self._time = time
self._last_version = Version.default()
self._first_version = Version.default()
self._flags = set()
self._load()
def _load(self):
path = self._config.get(Setting.DATA_CACHE_FILE_PATH)
if not JsonFileSaver.exists(path):
self._data = {NECESSARY_OLD_BACKUP_PLURAL_NAME: {}}
else:
self._data = JsonFileSaver.read(path)
# Check for an upgrade.
if KEY_LAST_VERSION in self._data:
self._last_version = Version.parse(self._data[KEY_LAST_VERSION])
if self.previousVersion != self.currentVersion:
# add an upgrade marker
if KEY_UPGRADES not in self._data:
self._data[KEY_UPGRADES] = []
self._data[KEY_UPGRADES].append({
'prev_version': str(self.previousVersion),
'new_version': str(self.currentVersion),
'date': self._time.now().isoformat()
})
self._data[KEY_LAST_VERSION] = str(self.currentVersion)
self.makeDirty()
if KEY_UPGRADES not in self._data or len(self._data[KEY_UPGRADES]) == 0:
self._first_version = self.currentVersion
else:
self._first_version = Version.parse(self._data[KEY_UPGRADES][0]['new_version'])
if self._config.isExplicit(Setting.IGNORE_OTHER_BACKUPS) or self._config.isExplicit(Setting.IGNORE_UPGRADE_BACKUPS):
self.addFlag(UpgradeFlags.NOTIFIED_ABOUT_IGNORED_BACKUPS)
if self.notifyForIgnoreUpgrades:
self._config.useLegacyIgnoredBehavior(True)
self.saveIfDirty()
def save(self, data=None):
if data is None:
data = self._data
path = self._config.get(Setting.DATA_CACHE_FILE_PATH)
JsonFileSaver.write(path, data)
self._dirty = False
def makeDirty(self):
self._dirty = True
@property
def dirty(self) -> bool:
return self._dirty
@property
def backups(self) -> Dict[str, Dict[str, str]]:
if NECESSARY_OLD_BACKUP_PLURAL_NAME not in self._data:
self._data[NECESSARY_OLD_BACKUP_PLURAL_NAME] = {}
return self._data[NECESSARY_OLD_BACKUP_PLURAL_NAME]
def backup(self, slug) -> Dict[str, str]:
if slug not in self.backups:
self.backups[slug] = {}
return self.backups[slug]
def saveIfDirty(self):
if self._dirty:
# See if we need to remove any old entries
for slug in list(self.backups.keys()):
data = self.backups[slug].get(KEY_LAST_SEEN)
if data is not None and self._time.now() > self._time.parse(data) + timedelta(days=CACHE_EXPIRATION_DAYS):
del self.backups[slug]
self.save()
@property
def previousVersion(self):
return self._last_version
@property
def firstVersion(self):
return self._first_version
@property
def currentVersion(self):
return Version.parse(VERSION)
@property
def notifyForIgnoreUpgrades(self):
return self.firstVersion < VERSION_DEFUALT_IGNORE_UPGRADES and not self.checkFlag(UpgradeFlags.NOTIFIED_ABOUT_IGNORED_BACKUPS) and not self._config.isExplicit(Setting.IGNORE_OTHER_BACKUPS) and not self._config.isExplicit(Setting.IGNORE_UPGRADE_BACKUPS)
def checkFlag(self, flag: UpgradeFlags):
return flag.value in self._data.get(KEY_FLAGS, [])
def TESTS_ONLY_clearFlags(self):
self._data[KEY_FLAGS] = []
def addFlag(self, flag: UpgradeFlags):
all_flags = set(self._data.get(KEY_FLAGS, []))
all_flags.add(flag.value)
self._data[KEY_FLAGS] = list(all_flags)
self.makeDirty()
def getUpgradeTime(self, version: Version):
for upgrade in self._data[KEY_UPGRADES]:
if Version.parse(upgrade['new_version']) >= version:
return self._time.parse(upgrade['date'])
return self._time.now()
@@ -0,0 +1,97 @@
import asyncio
import firebase_admin
from firebase_admin import credentials
from firebase_admin import firestore
from pathlib import Path
KNWON_ERRORS = ['existing_backup_folder', 'google_dns', 'cancelled', 'google_timeout', 'low_space', 'multiple_deletes']
async def main():
cred = credentials.Certificate(str(Path.home().joinpath("Documents/secrets/server-firestore-creds.json")))
firebase_admin.initialize_app(cred)
db = firestore.client()
reports = db.collection(u'error_reports')
while(True):
command = input()
if command == "clear all":
print("Deleteing records...")
batch = db.batch()
batch_size = 0
for item in reports.stream():
batch.delete(reports.document(item.id))
batch_size += 1
batch.commit()
print("Done")
if command == "":
# Do regular analysis
await stats(db, reports)
if command.startswith("get "):
query = reports.where("report.client", "==", command[4:])
for report in query.get():
print(report.to_dict())
print("done")
elif command == "exit" or command == "quit" or command == "q":
return
# stream = reports.stream()
for report in query.get():
print(report.to_dict())
async def stats(db, reports):
print("Inspecting the last 24 hours of records...")
cursor = None
batch_size = 50
summary = {}
resolved = {}
total = 0
while True:
count = 0
if cursor is None:
stream = reports.limit(batch_size).stream()
else:
stream = reports.limit(batch_size).start_after(cursor).stream()
for report in stream:
cursor = report
data = report.to_dict()
count += 1
total += 1
if 'report' not in data:
continue
if 'error' not in data['report']:
resolved[data['client']] = data['report']['duration']
continue
if data['report']['error'] in summary:
summary[data['report']['error']].append(data)
else:
summary[data['report']['error']] = [data]
print("Processed " + str(count) + " records")
if count == 0:
break
for error in summary.keys():
print("")
distinct = set()
unresolved = {}
versions = set()
for report in summary[error]:
distinct.add(report['client'])
if report['client'] not in resolved:
unresolved[report['client']] = report
versions.add(report['version'])
print("Unresolved: " + str(len(unresolved)))
print("Count: " + str(len(summary[error])))
print("Distinct: " + str(len(distinct)))
print("Versions: " + str(versions))
print("Error: " + error)
if len(unresolved) > 0:
print("Unresolved: ")
for unres in unresolved.values():
print(" " + unres['report']['arch'] + " " + unres['client'])
print("Total records: " + str(total))
if __name__ == '__main__':
asyncio.run(main())
@@ -0,0 +1,105 @@
import os
import platform
from injector import inject, singleton
from ..config import Config, Setting
from ..exceptions import LowSpaceError
from .globalinfo import GlobalInfo
from ..logger import getLogger
logger = getLogger(__name__)
SIZE_SI = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
@singleton
class Estimator():
@inject
def __init__(self, config: Config, global_info: GlobalInfo):
super().__init__()
self.config = config
self._blocksUsed = 0
self._blocksTotal = 1
self._blockSize = 0
self._global_info = global_info
def refresh(self):
if platform.system() == "Windows":
# Unsupported on windows
return self
# This roughly matches the results you get by running "df",
# except its a bit more conservative, which isn't necessarily
# correct, but we're aiming for ballpark numbers here so it
# should be ok.
stats = os.statvfs(self.config.get(Setting.BACKUP_DIRECTORY_PATH))
total = stats.f_blocks
available = stats.f_bavail
availableToRoot = stats.f_bfree
self._blocksUsed = total - availableToRoot
self._blocksTotal = self._blocksUsed + available
self._blockSize = stats.f_frsize
return self
def checkSpace(self, backups):
if platform.system() == "Windows":
# Unsupported on windows
return
if not self.config.get(Setting.WARN_FOR_LOW_SPACE):
# Don't check, just go
return
try:
self._checkSpace(backups)
except Exception as e:
if isinstance(e, LowSpaceError):
raise e
# Just log the error and continue otherwise
logger.error(
"Encountered an error while trying to check disk space remaining: " + str(e))
def _checkSpace(self, backups):
# get the most recent backup size
space_needed = self.config.get(Setting.LOW_SPACE_THRESHOLD)
backups.sort(key=lambda s: s.date(), reverse=True)
for backup in backups:
latest_size = backup.sizeInt()
if latest_size > 1:
# Bump the size a little to estimate organic growth.
space_needed = latest_size * 1.1
break
if space_needed > self.getBytesFree() and not self._global_info.isSkipSpaceCheckOnce():
raise LowSpaceError("{0}%".format(
int(self.getUsagePercent())), Estimator.asSizeString(self.getBytesFree()))
def getUsagePercent(self):
if self.getBlocksTotal() == 0:
return 0
return 100.0 * float(self.getBlocksUsed()) / float(self.getBlocksTotal())
def getBlocksUsed(self):
return self._blocksUsed
def getBlocksTotal(self):
return self._blocksTotal
def getBlocksFree(self):
return self.getBlocksTotal() - self.getBlocksUsed()
def getBytesFree(self):
return self._blockSize * self.getBlocksFree()
def getBytesUsed(self):
return self._blockSize * self.getBlocksUsed()
def getBytesTotal(self):
return self._blockSize * self.getBlocksTotal()
@classmethod
def asSizeString(cls, size):
current = float(size)
for id in SIZE_SI:
if current < 1024:
return "{0} {1}".format(round(current, 1), id)
current /= 1024
return "Beyond mortal comprehension"
@@ -0,0 +1,125 @@
from datetime import timedelta
from threading import Lock
from injector import inject, singleton
from ..time import Time
from ..logger import getLogger
logger = getLogger(__name__)
@singleton
class GlobalInfo():
@inject
def __init__(self, time: Time):
self._time = time
self._syncs = 0
self._failures = 0
self._last_error = 0
self._successes = 0
self._last_failure_time = None
self._uploads = 0
self._last_upload = None
self._last_success = time.now()
self._last_sync_success = None
self._last_upload_size = None
self._last_sync_start = None
self._last_error = None
self._supress_error = False
self.credVersion = 0
self._first_sync = True
self._multipleDeletesPermitted = False
self._dns_info = None
self._skip_space_check_once = False
self._ignore_errors_for_now = False
self._start_time = time.now()
self.ha_ssl = False
self.addons = None
self.ha_port = None
self.slug = None
self.url = ""
self.debug = {}
self.lock = Lock()
self.backup_cooldown_time = time.now()
def ignoreErrorsForNow(self):
return self._ignore_errors_for_now
def setIngoreErrorsForNow(self, value):
self._ignore_errors_for_now = value
def failureCount(self):
return self._failures
def refresh(self):
pass
def setDnsInfo(self, info):
self._dns_info = info
@property
def start_time(self):
return self._start_time
def getDnsInfo(self):
return self._dns_info
def success(self):
self._first_sync = False
self._last_error = None
self._last_success = self._time.now()
self._last_sync_success = self._time.now()
self._successes += 1
self._multipleDeletesPermitted = False
self.setIngoreErrorsForNow(False)
def sync(self):
self._last_sync_start = self._time.now()
self._syncs += 1
def failed(self, error):
self._first_sync = False
self._last_error = error
self._failures += 1
self._last_failure_time = self._time.now()
self._supress_error = False
self.setIngoreErrorsForNow(False)
def suppressError(self):
self._supress_error = True
def isErrorSuppressed(self):
return self.suppressError
def upload(self, size):
self._last_upload = self._time.now()
self._uploads += 1
self._last_upload_size = size
def credsSaved(self):
self.credVersion += 1
def isPermitMultipleDeletes(self) -> bool:
return self._multipleDeletesPermitted
def allowMultipleDeletes(self) -> bool:
self._multipleDeletesPermitted = True
def addDebugInfo(self, key, value):
with self.lock:
self.debug[key] = value
def isSkipSpaceCheckOnce(self):
return self._skip_space_check_once
def setSkipSpaceCheckOnce(self, val):
self._skip_space_check_once = val
def triggerBackupCooldown(self, delay: timedelta):
self.backup_cooldown_time = self._time.now() + delay
def backupCooldownTime(self):
return self.backup_cooldown_time
@@ -0,0 +1,56 @@
from typing import List, TypeVar, Generic, Mapping
T = TypeVar('T')
K = TypeVar('K')
class RangeLookup(Generic[T, K]):
def __init__(self, source: List[T], mapping: Mapping[T, K]):
self.source = source
self.map = mapping
def matches(self, start: K, end: K):
range_start = self._searchFirstGreaterOrEqual(start)
range_end = self._searchLastLessOrEqual(end)
x = range_start
while (x <= range_end):
if x >= 0 and x < len(self.source):
yield self.source[x]
x += 1
def matchList(self, start, end):
ret = []
for x in self.matches(start, end):
ret.append(x)
return ret
def _searchFirstGreaterOrEqual(self, val):
first = 0
last = len(self.source) - 1
if self.map(self.source[last]) < val:
return last + 1
while first != last:
mid = int((first + last) / 2)
if self.map(self.source[mid]) < val:
first = mid + 1
else:
last = mid
return first
def _searchLastLessOrEqual(self, val):
first = 0
last = len(self.source) - 1
if self.map(self.source[0]) > val:
return -1
while first != last:
mid = int((first + last) / 2)
if self.map(self.source[mid]) > val:
last = mid - 1
elif first != mid:
first = mid
elif self.map(self.source[last]) <= val:
return last
else:
return first
return first
@@ -0,0 +1,58 @@
import socket
from typing import Any, Dict, List
import aiodns
from aiohttp.resolver import AsyncResolver
from injector import inject, singleton
from ..config import Config, Setting
from ..logger import getLogger
logger = getLogger(__name__)
TTL_HOURS = 12
@singleton
class Resolver(AsyncResolver):
@inject
def __init__(self, config: Config):
super().__init__()
self.config = config
self._original_dns = self._resolver
self.setAlternateResolver()
config.subscribe(self.updateConfig)
async def resolve(self, host: str, port: int = 0,
family: int = socket.AF_INET) -> List[Dict[str, Any]]:
if host == self.config.get(Setting.DRIVE_HOST_NAME) and len(self.config.get(Setting.DRIVE_IPV4)) > 0 and family == 0:
# return the "mocked" drive address instead.
return [{
'family': 0,
'flags': socket.AddressInfo.AI_NUMERICHOST,
'port': port,
'proto': 0,
'host': self.config.get(Setting.DRIVE_IPV4),
'hostname': host
}]
addresses = await super().resolve(host, port, family)
return addresses
def updateConfig(self):
if self._alt_ns != self.config.get(Setting.ALTERNATE_DNS_SERVERS):
self.setAlternateResolver()
self._resolver = self._alt_dns
def setAlternateResolver(self):
if len(self.config.get(Setting.ALTERNATE_DNS_SERVERS)) > 0:
self._alt_dns = aiodns.DNSResolver(loop=self._loop, nameservers=self.config.get(
Setting.ALTERNATE_DNS_SERVERS).split(","))
else:
self._alt_dns = self._original_dns
self._alt_ns = self.config.get(Setting.ALTERNATE_DNS_SERVERS)
def toggle(self):
if self._resolver == self._original_dns:
self._resolver = self._alt_dns
else:
self._resolver = self._original_dns