Mini Shell
"""Backup size estimation functions"""
from datetime import datetime
from subprocess import DEVNULL, PIPE, run
import time
import json
import sys
from pathlib import Path
import logging
from typing import Dict, List, Union, TYPE_CHECKING
from .bakauth import BakAuthError, get_vded_quota, post_vded_size
if TYPE_CHECKING:
from ..configs import Conf
class SizeCache:
"""Calculates and caches backup usage in MiB"""
def __init__(self, conf: 'Conf', quota: int):
self.quota = quota
self.conf = conf
self.path = Path('/opt/bakmgr/var/.sizes_cache.json')
try:
self.stamp = self.path.stat().st_mtime
with self.path.open(encoding='ascii') as handle:
data = json.load(handle)
if conf.files.enabled and data['files'] is not None:
self.files = float(data['files'])
else:
self.files = None
if conf.mysql.enabled and data['mysql'] is not None:
self.mysql = float(data['mysql'])
else:
self.mysql = None
if conf.pgsql.enabled and data['pgsql'] is not None:
self.pgsql = float(data['pgsql'])
else:
self.pgsql = None
except (ValueError, KeyError, FileNotFoundError):
self.stamp = 0.0
self.mysql = 0.0
self.files = 0.0
self.pgsql = 0.0
@property
def time_str(self) -> str:
if self.stamp > 0.0:
return datetime.fromtimestamp(self.stamp).strftime("%d/%m/%y %I%p")
raise AttributeError
@property
def total(self):
return sum([x for x in (self.mysql, self.files, self.pgsql) if x])
@property
def retain(self) -> int:
"""Number of backups to retain"""
if self.total < self.quota:
return 2
try:
val = int(self.quota / self.total)
except ZeroDivisionError:
return 10
return min(max(2, val), 10) # between 2 and 10, inclusive
@property
def __dict__(self):
return {
'files': self.files,
'mysql': self.mysql,
'pgsql': self.pgsql,
}
def get(self) -> int:
"""Get total backup usage in MiB"""
age_hrs = abs(time.time() - self.stamp) / 3600
if age_hrs < 24 and self.total < self.quota:
# accept cache: cache < 1day old, under quota
return self.total
if age_hrs < 2 and self.total > self.quota:
# accept cache: cache < 2h old, over quota
return self.total
self._recalc()
return self.total
def _recalc(self):
"""Recalculate backup usage"""
if self.conf.files.enabled:
self.files = du_mb(self.conf.files.include, self.conf.files.exclude)
else:
self.files = None
if self.conf.mysql.enabled:
self.mysql = du_mb(['/var/lib/mysql'], [])
else:
self.mysql = None
if self.conf.pgsql.enabled:
self.pgsql = du_mb(['/var/lib/pgsql'], [])
else:
self.pgsql = None
with self.path.open('w', encoding='ascii') as handle:
json.dump(vars(self), handle, indent=4)
def check_quota(conf: 'Conf') -> int:
"""pre-backup quota validation"""
quota = get_vded_quota()
if quota <= 0:
logging.error('No backup subscription found')
sys.exit(1)
cache = SizeCache(conf, quota)
total = int(cache.get())
try:
post_vded_size(
total,
notify=total > quota,
reset=total <= quota,
)
except BakAuthError as exc:
logging.warning(exc)
if total <= quota:
logging.info('Usage: %dM/%dM', total, quota)
return cache.retain
logging.error('Usage: %dM/%dM', total, quota)
sys.exit(3)
def du_mb(
paths: Union[List[str], List[Path]],
exclude: Union[List[str], List[Path]],
) -> Dict[str, int]:
"""Get total of a list of paths in MiB
Args:
paths (list[str]): paths to calculate size of
exclude (list[str]): exclude these subpaths from above
Raises:
ValueError: unexpected output from du
IndexError: unexpected output from du
Returns:
dict[str, int]: paths and sizes in MiB
"""
if not paths:
return {}
kwargs = {}
cmd = ['du', '-0sB', '1M']
if exclude:
cmd.append('--exclude-from=-')
kwargs['input'] = '\n'.join([str(x) for x in exclude]) + '\n'
cmd.extend([str(x) for x in paths if Path(x).exists()])
# check=False becasue running du against a list where one item does not
# exist will exit 1 and for our purposes that's fine
stdout = run(
cmd,
check=False,
stdout=PIPE,
stderr=DEVNULL,
encoding='UTF-8',
**kwargs,
).stdout
# parse the result
total = 0
for row in stdout.strip('\0').split('\0'):
if not row:
continue
size, _ = row.split('\t', 1)
total += int(size)
return total
Zerion Mini Shell 1.0