ok
Direktori : /opt/imunify360/venv/lib64/python3.11/site-packages/restore_infected/backup_backends/ |
Current File : //opt/imunify360/venv/lib64/python3.11/site-packages/restore_infected/backup_backends/acronis.py |
"""Interface to Acronis file restoration API""" import functools import json import logging import operator import os import platform import re import resource import socket import time import urllib.parse as urlparse # path differs from python 2.7! from contextlib import closing from itertools import chain from multiprocessing import Pool from pathlib import Path from subprocess import PIPE, Popen, run from typing import Dict, Iterable, List, Optional from xml.etree import ElementTree import requests from restore_infected import __version__ from restore_infected.backup_backends_lib import ( BackendNotAuthorizedError, backend_auth_required, backup_client_required, ) from .. import helpers, package_manager from ..backup_backends_lib import asyncable, extra from ..helpers import from_env REPEAT_NUM = 30 REPEAT_WAIT = 5 # seconds BACKUP_ATTEMPTS = 2 TOKEN_FILE = '/var/restore_infected/acronis_api_token.json' auth_required = backend_auth_required( TOKEN_FILE, "Initialize Acronis first!") client_required = backup_client_required( '/usr/lib/Acronis/BackupAndRecovery/mms', 'Acronis client should be installed first!') def is_suitable(): return True class FileDeliverError(Exception): pass class BackupFailed(Exception): pass class TokenExistsError(Exception): pass class ClientAlreadyInstalledError(Exception): pass class RequestFailed(Exception): pass def check_response(response): """ Exit with error status unless response code is 200 :param response: obj -> response object """ try: response.raise_for_status() except requests.HTTPError as e: if e.response.status_code == 401: raise BackendNotAuthorizedError("Token is invalid") from e raise e class AcronisConnector: api_path = 'api/1' entrypoint = 'https://cloud.acronis.com' refresh_interval = 86400 # one day # URL templates accounts_tpl = '{}/{}/accounts?login={}' token_api_tpl = '{}/api/idp/v1/token' login_tpl = '{}/{}/login' logout_tpl = '{}/{}/logout' bredirect_url_tpl = '{}/{}/groups/{}/backupconsole' backup_url_api_tpl = '{}api/ams' token_path = TOKEN_FILE # for backward compatibility def __init__(self, hostname=None): self.hostname = hostname if hostname else socket.gethostname() self._session = requests.Session() self._session.headers.update( {"User-Agent": "cloudlinux-backup-utils v{}".format(__version__)} ) self._logged_in = False self.server_url = None @classmethod def connect(cls, hostname=None): _class = cls(hostname) _class.login() return _class def get(self, url, **kw): return self._session.get('{}/{}'.format(self.base_url, url), **kw) def post(self, url, **kw): return self._session.post('{}/{}'.format(self.base_url, url), **kw) def put(self, url, **kw): return self._session.put('{}/{}'.format(self.base_url, url), **kw) def login(self): """ Creates a session to work with restoration API """ self._login_to_api() self._get_url() self._logged_in = True def logout(self): """ Destroys the session """ url = self.logout_tpl.format(self.server_url, self.api_path) check_response(self._session.post(url)) self._logged_in = False @classmethod def create_token(cls, username, password, force=False): """ Gets and saves authentication token using provided credentials :param username: str -> username :param password: str -> password :param force: bool -> overwrite token file """ if os.path.exists(TOKEN_FILE) and not force: raise TokenExistsError("Token file already exists. If you want to " "override it, delete '{}' manually and run " "init again.".format(TOKEN_FILE)) server_url = cls.get_server_url(username) url = cls.token_api_tpl.format(server_url) params = { 'grant_type': 'password', 'username': username, 'password': password, 'client_id': 'backupAgent'} r = requests.post(url, data=params) check_response(r) data = r.json() data['timestamp'] = int(time.time()) data['username'] = username for key in 'token_type', 'access_token': data.pop(key, None) cls._save_token(data) def refresh_token(self): """ Refreshes old token with old token refresh key """ self._refresh_token() @staticmethod def get_saved_token(): """Reads token from file""" try: with open(TOKEN_FILE) as f: return json.load(f) except (OSError, IOError, ValueError, TypeError) as e: raise Exception("Could not read token: {}".format(str(e))) def _login_to_api(self): """Log into common API""" if self._logged_in: return token_data = self.get_saved_token() if token_data is None or 'username' not in token_data: raise Exception('Missing or incomplete credentials. Exit') if not self.server_url: self.server_url = self.get_server_url(token_data['username']) if time.time() - token_data['timestamp'] > self.refresh_interval: self._refresh_token(token_data) url = self.login_tpl.format(self.server_url, self.api_path) params = {'jwt': token_data.get('id_token')} r = self._session.get(url, params=params) check_response(r) self._user_data = r.json() def _get_url(self): """After common API login get redirected to backup API""" if hasattr(self, 'url'): return if not hasattr(self, '_user_data'): raise Exception("No user data. Call basic_login first") url = self.bredirect_url_tpl.format( self.server_url, self.api_path, self._user_data.get('group', {}).get('id')) r = self._session.get(url) check_response(r) redirect_url = r.json().get('redirect_url') r = self._session.get(redirect_url) self.base_url = self.backup_url_api_tpl.format(r.url) self._subscription_url = self.base_url.replace('ams', 'subscriptions') @classmethod def get_server_url(cls, username): """ To start working we have to get our working server URL from the main entrypoint :param username: str -> username to get working server :return: str -> working server URL """ server_url = prev_server_url = cls.entrypoint for _ in range(10): url = cls.accounts_tpl.format(server_url, cls.api_path, username) r = requests.get(url) check_response(r) server_url = r.json().get("server_url") if server_url == prev_server_url: break prev_server_url = server_url return server_url def _refresh_token(self, token=None): """ Gets new token and passes it for saving :param token: dict -> old token data as dict :return: dict -> refreshed token """ if token is None: # no token passed token = self.get_saved_token() if token is None: # getting token from file failure raise Exception('Could not get saved token to refresh') if not self.server_url: self.server_url = self.get_server_url(token['username']) url = self.token_api_tpl.format(self.server_url) params = { 'grant_type': 'refresh_token', 'refresh_token': token['refresh_token'], 'client_id': 'backupAgent'} r = self._session.post(url, data=params) check_response(r) data = r.json() for key in 'refresh_token', 'id_token': token[key] = data.get(key) token['timestamp'] = int(time.time()) self._save_token(token) return token @classmethod def _save_token(cls, token): """ Saves token to file :param token: dict -> token data to be saved :return: bool -> True if succeeded and False otherwise """ helpers.mkdir(os.path.dirname(TOKEN_FILE)) try: with open(TOKEN_FILE, 'w') as f: json.dump(token, f) except (OSError, IOError, ValueError, TypeError) as e: raise Exception("Could not save token to file: {}".format(e)) return True def get_login_url(self): """ Get login url to sign up without username/password prompt :return: str -> login url """ token_data = self._refresh_token() id_token = token_data.get('id_token') if not id_token: raise Exception("Error obtaining login token") username = token_data['username'] if not self.server_url: self.server_url = self.get_server_url(username) return "{}?jwt={}".format(self.server_url, id_token) def subscription(self, sid=None, params=None): if params is None: params = {} url = '/'.join(filter(None, (self._subscription_url, sid))) headers = {'Content-Type': 'application/json'} r = self._session.post(url, headers=headers, json=params) check_response(r) return r.json() @functools.total_ordering class FileData: def __init__(self, item_id, filename, size, mtime): self.id = item_id self.filename = filename self.size = size self.mtime = mtime def __str__(self): return "{}; {}; {}; {}".format( self.id, self.filename, self.size, self.mtime.isoformat(), ) def __repr__(self): return ( "<{__class__.__name__}" " (filename={filename!s}, mtime={mtime!r})>".format( __class__=self.__class__, **self.__dict__ ) ) def __lt__(self, other): return self.mtime < other.mtime def __eq__(self, other): return self.mtime == other.mtime def __hash__(self): return hash((self.id, self.filename, self.size, self.mtime)) class AcronisRestoreItem(FileData): def __init__(self, item, restore_prefix, destination_folder): super().__init__( item_id=item.id, filename=item.filename, size=item.size, mtime=item.mtime) self.restore_prefix = restore_prefix self.destination_folder = destination_folder @property def restore_path(self): # /tmp/tmpdir234234/home/user/public_html/blog/index.php return os.path.join( self.destination_folder, os.path.relpath(self.filename, self.restore_prefix) ) @classmethod def get_chunks( cls, items, n: int, temp_dir: str ) -> Iterable[List["AcronisRestoreItem"]]: common_prefix = common_path(items) offset = 0 chunk = items[offset: offset + n] while chunk: restore_prefix = common_path(chunk) destination_folder = os.path.join( temp_dir, os.path.relpath(restore_prefix, common_prefix) ) yield [ cls(item, restore_prefix, destination_folder) for item in chunk ] offset += n chunk = items[offset: offset + n] def common_path(items): if len(items) == 1: return os.path.dirname(items[0].filename) return os.path.commonpath([item.filename for item in items]) class AcronisBackup: _contents_tpl = ( "archives/<patharchiveId>/backups/<pathbackupId>/items?" "type={}&machineId={}&backupId={}&id={}" ) _restore_session_tpl = "archives/downloads?machineId={}" _download_item_tpl = ( "archives/downloads/{}/<pathfileName>?" "fileName={}&format={}&machineId={}&start_download=1" ) _draft_request_tpl = "recovery/plan_drafts?machineId={}" _target_loc_tpl = "recovery/plan_drafts/{}/target_location?machineId={}" _create_recovery_plan_tpl = "recovery/plan_drafts/{}" _run_recovery_plan_tpl = "recovery/plan_operations/run?machineId={}" default_download_dir = '/tmp' jheaders = {"Content-Type": "application/json"} restore_fmt = "PLAIN" chunk_size = 50 def __init__(self, conn, host_id, backup_id, created): self._conn = conn self.host_id = host_id self.backup_id = backup_id self.created = created logging.getLogger(__name__).addHandler(logging.NullHandler()) def __str__(self): return "{}".format(self.created.isoformat()) def __repr__(self): return "<{__class__.__name__} (created={created!r})>".format( __class__=self.__class__, **self.__dict__ ) def __eq__(self, other): return self.created == other.created def _get_draft_request(self, items): payload = { 'backupId': self.backup_id, 'operationId': 'rec-draft-2-4', 'machineId': self.host_id, 'resourcesIds': [item.id for item in items]} url = self._draft_request_tpl.format(self.host_id) response = self._conn.post(url, headers=self.jheaders, json=payload) check_response(response) return response.json() def _set_target_location(self, draft, destination_folder: str): payload = { 'machineId': self.host_id, 'type': 'local_folder', 'path': destination_folder} url = self._target_loc_tpl.format(draft.get('DraftID'), self.host_id) response = self._conn.put(url, headers=self.jheaders, json=payload) check_response(response) def _create_recovery_plan(self, draft): payload = {'operationId': 'rec-createplan-1-1'} url = self._create_recovery_plan_tpl.format(draft.get('DraftID')) r = self._conn.post(url, headers=self.jheaders, json=payload) check_response(r) return r.json() def _run_recovery_plan(self, plan): payload = {'machineId': self.host_id, 'operationId': 'noWait', 'planId': plan.get('PlanID')} url = self._run_recovery_plan_tpl.format(self.host_id) r = self._conn.post(url, headers=self.jheaders, json=payload) check_response(r) def _get_restore_session(self, file_path): """ Gets session for downloading a selected item :return: str -> session ID """ url = self._restore_session_tpl.format(self.host_id) json_params = { 'format': self.restore_fmt, 'machineId': self.host_id, 'backupId': self.backup_id, 'backupUri': self.backup_id, 'items': [file_path]} r = self._conn.post(url, json=json_params) check_response(r) return r.json().get('SessionID') def _download_backup_item(self, session_id, item: AcronisRestoreItem): """ Downloads chosen item from acronis backup to current host :param session_id: str -> download session token """ file_name = os.path.basename(item.filename) url = self._download_item_tpl.format( session_id, file_name, self.restore_fmt, self.host_id ) r = self._conn.get(url) check_response(r) os.makedirs( os.path.dirname(item.restore_path), mode=0o700, exist_ok=True ) with open(item.restore_path, "wb") as f: f.write(r.content) def _get_path_item(self, host_id, backup_id, wanted_path, path_id=None): """ Recursively gets path items :param wanted_path: str -> file path to restore :param path_id: str -> acronis path ID or None :return: dict -> found backup item or None """ wanted_path_parents = Path(wanted_path).parents try: result = self._get_path_item_inner(host_id, backup_id, path_id) if not result: return # There are no data. So return except RequestFailed: return for item in result: item_path = _strip_device_name(item.get('fullPath')) if item_path == wanted_path: # We've found item we search for return item if Path(item_path) == Path('/'): # If current path is a root path # it means that we selected some volume. We will try to find # needed file in that volume, if failed -- we have to look in # another volume return_value = self._get_path_item( host_id, backup_id, wanted_path, item.get('id') ) if return_value: # We found file in the current volume return return_value else: # Look in the another volume continue elif Path(item_path) in wanted_path_parents: # we're on the right way return self._get_path_item( host_id, backup_id, wanted_path, item.get('id') ) def __hash__(self): # quick hack for lru_cache return hash((type(self), self.host_id, self.backup_id)) @functools.lru_cache(maxsize=128) def _get_path_item_inner(self, host_id, backup_id, path_id=None): """ Gets path items :param host_id: str -> acronis host ID :param backup_id: str -> acronis backup ID :param path_id: str -> acronis path ID or None :return: list -> found backup items :raise: RequestFailed """ if path_id is None: path_id = '' backup_type = 'files' url = self._contents_tpl.format( backup_type, host_id, urlparse.quote_plus(backup_id), urlparse.quote_plus(path_id)) params_for_json = { 'id': path_id, 'backupId': backup_id, 'machineId': host_id, 'type': backup_type} try: r = self._conn.post(url, json=params_for_json) except requests.exceptions.ConnectionError as e: logging.error('Fetching backup data failure: {}'.format(e)) # we don't want it to get cached raise RequestFailed('Fetching backup data failure: {}'.format(e)) if r.status_code != 200: raise RequestFailed( 'Fetching backup data failure: status code {}'.format( r.status_code, ), ) result = r.json() if result.get('total') == 0 or len(result.get('data', [])) == 0: return [] return [{ 'id': item.get('id'), 'fullPath': item.get('fullPath'), 'size': item.get('size'), 'lastChange': item.get('lastChange'), } for item in result.get('data', [])] def _get_backup_contents(self, paths): """ Frontend for recursive backup contents getter :param paths: list -> list of file paths to restore """ runner = functools.partial( self._get_path_item, self.host_id, self.backup_id) total = len(paths) pool = Pool(5 if total > 5 else total) results = pool.map(runner, paths) self.contents = [] for idx, value in enumerate(results): if value is None: raise FileNotFoundError(paths[idx]) self.contents.append( FileData( item_id=value.get('id'), filename=value.get('fullPath'), size=value.get('size'), mtime=helpers.DateTime(value.get('lastChange', '')), ) ) def file_data(self, path): result = self._get_path_item(self.host_id, self.backup_id, path) if result is None: raise FileNotFoundError(path) return FileData( item_id=result.get('id'), filename=_strip_device_name(result.get('fullPath')), size=result.get('size'), mtime=helpers.DateTime(result.get('lastChange', '')), ) @staticmethod def wait_for_arrival(items, timeout): yet_to_arrive = set(items) start = time.monotonic() while time.monotonic() - start < timeout: yet_to_arrive = { item for item in yet_to_arrive if not ( os.path.exists(item.restore_path) and item.size == os.stat(item.restore_path).st_size ) } if not yet_to_arrive: break time.sleep(1) return yet_to_arrive def restore( self, items: List[FileData], destination_folder: Optional[str], timeout=600, use_download=False, ) -> Dict[str, str]: if not items: return {} destination_folder = destination_folder or self.default_download_dir if use_download: # FIXME: figure out how to download the items in bulk as well restore_prefix = common_path(items) restore_items = [ AcronisRestoreItem(item, restore_prefix, destination_folder) for item in items ] for item in restore_items: session_id = self._get_restore_session(item.filename) self._download_backup_item(session_id, item) else: chunk_list = list( AcronisRestoreItem.get_chunks( items, self.chunk_size, destination_folder ) ) for chunk in chunk_list: recovery_draft = self._get_draft_request(chunk) self._set_target_location( recovery_draft, chunk[0].destination_folder ) recovery_plan = self._create_recovery_plan(recovery_draft) self._run_recovery_plan(recovery_plan) restore_items = list(chain.from_iterable(chunk_list)) did_not_arrive = self.wait_for_arrival(restore_items, timeout) if did_not_arrive: raise FileDeliverError( "Some files ({}) have not been " "delivered in a given time slot ({})" .format(did_not_arrive, timeout) ) for item in restore_items: atime = helpers.DateTime.now().timestamp() mtime = item.mtime.timestamp() os.utime(item.restore_path, (atime, mtime)) return {item.restore_path: item.filename for item in restore_items} def close(self): pass class AcronisAPI: _archives_tpl = ('locations/<pathlocationId>/archives?' 'locationId={}&machineId={}') _backups_tpl = ('archives/<patharchiveId>/backups?' 'archiveId={}&machineId={}') _region_pattern = re.compile( r'(?P<region>\w+)(?:-cloud\.acronis|\.cloudlinuxbackup)\.com' ) _backup_and_recovery_config_path = '/etc/Acronis/BackupAndRecovery.config' backup_log_path = '/var/restore_infected/acronis_backup.log' default_start_time = {'hour': 5, 'minute': 0, 'second': 0} mysql_freeze_options = { 'prePostCommands': { 'preCommands': { 'command': '', 'commandArguments': '', 'continueOnCommandError': False, 'waitCommandComplete': True, 'workingDirectory': '', }, 'postCommands': { 'command': '', 'commandArguments': '', 'continueOnCommandError': False, 'waitCommandComplete': True, 'workingDirectory': '', }, 'useDefaultCommands': True, 'usePreCommands': False, 'usePostCommands': False, }, 'prePostDataCommands': { 'preCommands': { 'command': '/usr/libexec/cloudlinux-backup/pre-mysql-freeze-script', 'commandArguments': '', 'continueOnCommandError': False, 'waitCommandComplete': True, 'workingDirectory': '', }, 'postCommands': { 'command': '/usr/libexec/cloudlinux-backup/post-mysql-thaw-script', 'commandArguments': '', 'continueOnCommandError': False, 'waitCommandComplete': True, 'workingDirectory': '', }, 'useDefaultCommands': False, 'usePreCommands': True, 'usePostCommands': True, } } scheme = { 'schedule': { 'daysOfWeek': [ 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', ], 'startAt': default_start_time, 'type': 'weekly' }, 'type': 'alwaysIncremental' } def __init__(self, hostname=None, subscribe=False): self.hostname = hostname if hostname else socket.gethostname() self._conn = AcronisConnector.connect(self.hostname) if subscribe: self._sid = self._conn.subscription().get('id') if self._sid is None: raise Exception("Could not get subscription ID") logging.getLogger(__name__).addHandler(logging.NullHandler()) @classmethod def make_initial_backup(cls, plan_name='CloudLinux Backup'): _class = cls(subscribe=True) _class._get_hostinfo() plan = _class._select_plan() # Pick the first plan for this host if plan is not None: return _class._run_backup_plan(plan['id']) draft_id = _class._set_backup_plan_draft() params = {'planName': plan_name} _class._set_backup_plan_params(draft_id, params) _class._set_backup_plan_options(draft_id, cls.mysql_freeze_options) _class._set_backup_plan_scheme(draft_id, cls.scheme) plan_id = _class._create_backup_plan(draft_id) _class._run_backup_plan(plan_id) @classmethod def pull_backups(cls, until=None): _class = cls() _class._get_hostinfo() _class._get_host_archives() _class._get_archive_backups(until) return _class.backup_list @classmethod def migration_fix_backup_plan(cls): a = cls(subscribe=True) a._get_hostinfo() obsolete_time = {'hour': 18, 'minute': 0, 'second': 0} for plan in a._get_cloudlinux_plans(): draft_id = a._create_draft_from_plan(plan) a._set_backup_plan_options(draft_id, cls.mysql_freeze_options) if cls._get_plan_time(plan) == obsolete_time: a._set_backup_plan_scheme(draft_id, cls.scheme) a._save_draft(draft_id) def _get_hostid(self, config_path=None): if hasattr(self, 'hostid'): return self.hostid if not config_path: config_path = self._backup_and_recovery_config_path et = ElementTree.parse(config_path) root = et.getroot() for settings in root: if settings.tag == 'key' and settings.get('name') == 'Settings': break else: raise Exception("Can't find 'Settings' key entry in config file " "'%s'" % config_path) for machine_manager in settings: if machine_manager.tag == 'key' and \ machine_manager.get('name') == 'MachineManager': break else: raise Exception("Can't find 'MachineManager' key entry in " "'Settings'") for value in machine_manager: if value.tag == 'value' and \ value.get('name') == 'MMSCurrentMachineID': break else: raise Exception("Can't find 'MMSCurrentMachineID' value entry in " "'MachineManager'") self.hostid = value.text.strip().strip('"') return self.hostid def _refresh_hostinfo(self): """ Refreshes the existing hostinfo or saves a new one if not present. Returns True if successful, False otherwise. """ hostid = self._get_hostid() r = self._conn.get('resources') check_response(r) resources = r.json() for res in resources.get('data', []): if res.get('type') == 'machine' and res.get('hostId') == hostid: self.hostinfo = res return True return False def _get_hostinfo(self): """ Gets backup host info from acronis. Exits with error message unless host found. """ if hasattr(self, 'hostinfo'): return self.hostinfo if not _repeat(self._refresh_hostinfo): raise Exception("No backup for host {}".format(self.hostname)) def get_backup_progress(self): """ Returns the progress of a running backup in percents. """ if self.is_backup_running(): progress = self.hostinfo.get('progress') if progress: return int(progress) def get_backup_status(self): """ Returns last backup status. * ok - backup succeeded * error - backup failed * unknown - there were no backups * warning - backup succeeded, but there are alerts """ self._refresh_hostinfo() return self.hostinfo['status'] def is_backup_running(self): """ Returns True if there is a backup currently running or False otherwise. """ if self._refresh_hostinfo(): if self.hostinfo['state']: return self.hostinfo['state'][0] == 'backup' return False raise Exception("No backup for host {}".format(self.hostname)) def get_alerts(self, as_json=True): """ Get alerts for the current machine :param as_json: set to false to get alerts in human readable form :return list: list of alerts for the machine this backup belongs to """ r = self._conn.get('alerts') check_response(r) alerts_json = r.json() alerts = [alert for alert in alerts_json.get('data', []) if alert.get('meta', []).get('machineName') == self.hostname] if as_json: return alerts alerts_as_str = '' for alert in alerts: data = ': '.join( filter(None, [alert.get('title'), alert.get('description')])) if data: if alerts_as_str: alerts_as_str += '\n' alerts_as_str += data return alerts_as_str def _get_host_archives(self, location='cloud'): """ Gets host archives. Required archive names are expected to start with hostname :param location: str -> location where archive is saved """ self.archives = [] if not hasattr(self, 'hostinfo'): raise Exception('No host data. Has "_get_hostinfo" been run?') host_id = self.hostinfo.get('hostId') url = self._archives_tpl.format(location, host_id) r = self._conn.get(url) check_response(r) archives = r.json() for archive in archives.get('data', []): if any([archive.get('archiveName', '').startswith(self.hostname), archive.get('archiveMachineId') == host_id]): self.archives.append(archive) def _get_archive_backups(self, until=None): """ Gets list of backups (not contents!) from the archive :param until: str -> timestamp """ host_id = self.hostinfo.get('hostId') archive_ids = [i['id'] for i in self.archives] if until is not None and isinstance(until, str): until = helpers.DateTime(until) self.backup_list = [] for archive_id in archive_ids: url = (self._backups_tpl.format( urlparse.quote_plus(archive_id), host_id)) r = self._conn.get(url) if not r.ok: continue for backup in r.json().get('data', []): created = helpers.DateTime(backup.get('creationTime')) if until is not None and created < until: continue self.backup_list.append( AcronisBackup( conn=self._conn, host_id=host_id, backup_id=backup.get('id'), created=created)) self.backup_list.sort( key=operator.attrgetter('created'), reverse=True) def _apply_default_plan(self): payload = { 'resourcesIds': [self.hostinfo.get('id')] } r = self._conn.post('resource_operations/apply_default_backup_plan', headers={'Content-Type': 'application/json'}, json=payload) return r.status_code def _get_applicable_plans(self): payload = { 'resourcesIds': [self.hostinfo.get('id')] } r = self._conn.post( 'backup/plan_draft_operations/get_applicable_plans', headers={'Content-Type': 'application/json'}, json=payload, ) check_response(r) return r.json().get('Plans', []) def _get_plans(self): if hasattr(self, '_backup_plans'): return self._backup_plans r = self._conn.get('backup/plans', headers={'Content-Type': 'application/json'}) check_response(r) self._backup_plans = r.json()['data'] return self._backup_plans def _select_plan(self): if not hasattr(self, '_backup_plans'): self._get_plans() for plan in self._backup_plans: for source in plan['sources']['data']: hostid = self.hostinfo.get('hostId') source_id = self.hostinfo.get('id') if source.get('hostID') == hostid or \ hostid in source.get('machineId', '') or \ source_id == source.get('id', ''): return plan def _set_backup_plan_draft(self): url = 'backup/plan_drafts' payload = { 'subscriptionId': self._sid, 'operationId': 'bc-draft-{}-0'.format(self._sid), 'language': 'en', 'resourcesIds': [self.hostinfo.get('id')], 'defaultStartTime': self.default_start_time} return self._post_pop_request(url, payload)['DraftID'] def _set_backup_plan_params(self, draft_id, payload): url = 'backup/plan_drafts/{}/parameters'.format(draft_id) headers = {'Content-Type': 'application/json'} r = self._conn.put(url, headers=headers, json=payload) check_response(r) def _set_backup_plan_options(self, draft_id, payload): url = 'backup/plan_drafts/{}/options'.format(draft_id) headers = {'Content-Type': 'application/json'} r = self._conn.put(url, headers=headers, json=payload) check_response(r) def _set_backup_plan_scheme(self, draft_id, payload): url = 'backup/plan_drafts/{}/scheme'.format(draft_id) headers = {'Content-Type': 'application/json'} r = self._conn.put(url, headers=headers, json=payload) check_response(r) def _post_pop_request(self, url, payload): headers = {'Content-Type': 'application/json'} r = self._conn.post(url, headers=headers, json=payload) check_response(r) params = {'action': 'pop', 'timeout': 10} data = _repeat( self._conn.subscription, **{'sid': self._sid, 'params': params}) return data[0]['data']['result'] def _create_backup_plan(self, draft_id): url = 'backup/plan_drafts_operations/create_plan' payload = { 'subscriptionId': self._sid, 'operationId': 'bc-createplan-{}-1'.format(self._sid), 'draftId': draft_id} return self._post_pop_request(url, payload)['PlanID'] @staticmethod def _get_plan_time(plan): return plan["scheme"].get("schedule", {}).get("startAt") @staticmethod def _get_plan_options(plan): return plan.get('options').get('backupOptions') @classmethod def _is_cloudlinux_plan(cls, plan): return 'cloudlinux' in str(cls._get_plan_options(plan)) def _get_cloudlinux_plans(self): cl_plans = [] for plan in self._get_plans(): if self._is_cloudlinux_plan(plan): cl_plans.append(plan) return cl_plans def _create_draft_from_plan(self, plan): url = 'backup/plan_drafts' payload = { 'language': 'en', 'subscriptionId': self._sid, 'operationId': 'bc-draft-{}-1'.format(self._sid), 'planId': plan.get('id')} return self._post_pop_request(url, payload)['DraftID'] def _save_draft(self, draft_id): url = 'backup/plan_drafts_operations/save_plan' payload = { 'subscriptionId': self._sid, 'operationId': 'bc-saveplan-{}-1'.format(self._sid), 'draftId': draft_id} return self._post_pop_request(url, payload) def _run_backup_plan(self, plan_id): payload = { 'planId': plan_id, 'resourcesIds': [self.hostinfo.get('id')]} r = self._conn.post('backup/plan_operations/run', headers={'Content-Type': 'application/json'}, json=payload) check_response(r) def get_info(self): """ Get info from Acronis :return: dict """ storage_info = self._conn.get('online_storage_information') check_response(storage_info) storage_info_data = storage_info.json() usage = storage_info_data.get("usage", {}).get("storageSize") if usage is None: for offering_item in storage_info_data.get("offering_items", []): if offering_item.get("usage_name") == "total_storage": usage = offering_item.get("value") break self._get_hostinfo() plan = self._select_plan() if plan and plan["enabled"]: scheme = plan["scheme"] if scheme["type"] == "custom": schedule = scheme.get("scheduleItems", [None])[0] else: schedule = scheme.get("schedule") else: schedule = None region_match = self._region_pattern.search(self._conn.base_url) region = region_match.groupdict()['region'] if region_match else None info_data = { 'usage': usage, # space used by backups 'schedule': schedule, 'region': region, } token_data = self._conn.get_saved_token() for key in 'username', 'timestamp': info_data[key] = token_data.get(key) return info_data class AcronisClientInstaller: ACRONIS_BIN_LINK = '{endpoint}/bc/api/ams/links/agents/' \ 'redirect?language=multi&system=linux&' \ 'architecture={arch}&productType=enterprise' ACRONIS_BIN = 'acronis.bin' REG_BIN_OBSOLETE = '/usr/lib/Acronis/BackupAndRecovery/AmsRegisterHelper' REG_CMD_OBSOLETE = '{bin} register {endpoint} {login} {password}' REG_BIN = '/usr/lib/Acronis/RegisterAgentTool/RegisterAgent' REG_CMD = '{bin} -o register -t cloud -a {endpoint} ' \ '-u {login} -p {password}' ACRONIS_DEPS = ('gcc', 'make', 'rpm') ACRONIS_INSTALL_BIN_CMD = './{bin} ' \ '--auto ' \ '--rain={endpoint} ' \ '--login={login} ' \ '--password={password}' \ '{optional}' MAKE_EXECUTABLE_CMD = 'chmod +x {}' INSTALL_LOG = '/var/restore_infected/acronis_installation_{time}.log' DTS_REPO_URL = 'https://people.centos.org/tru/devtools-2/devtools-2.repo' DTS_DEPS = ('devtoolset-2-gcc', 'devtoolset-2-binutils') DTS_BIN_PATH = '/opt/rh/devtoolset-2/root/usr/bin' YUM_REPO_DIR = '/etc/yum.repos.d' DKMS_CONFIG = '/etc/dkms/framework.conf' DKMS_PATH_VAR = 'export PATH={path}:$PATH' + os.linesep UNINSTALL_BIN = '/usr/lib/Acronis/BackupAndRecovery/uninstall/uninstall' UNINSTALL_CMD = (UNINSTALL_BIN, '--auto') def __init__(self, server_url, log_to_file=True): self.server_url = server_url self.logger = logging.getLogger(self.__class__.__name__) if log_to_file: log_file = self.INSTALL_LOG.format(time=time.time()) self.logger.setLevel(logging.INFO) self.logger.addHandler(logging.FileHandler(log_file)) self.arch = '64' if not platform.machine().endswith('64'): self.arch = '32' self.pm = package_manager.detect() def _exec(self, command): self.logger.debug('exec: `%s`', command) process = Popen(command.split(' '), stdout=PIPE, stderr=PIPE) out, err = process.communicate() exit_code = process.returncode self.logger.debug('Return code: %d\n\tSTDOUT:\n%s\n\tSTDERR:\n%s', exit_code, out.decode('utf-8').strip(), err.decode('utf-8').strip()) return exit_code, out.decode('utf-8') def _download_binary(self): self.logger.info('\tSaving from %s for x%s to acronis.bin', self.server_url, self.arch) response = requests.get( self.ACRONIS_BIN_LINK.format(endpoint=self.server_url, arch=self.arch), allow_redirects=True, stream=True) response.raise_for_status() with open(self.ACRONIS_BIN, 'wb') as handle: for block in response.iter_content(1024): handle.write(block) @staticmethod def _check_rlimit_stack(): """ Acronis agent has wrappers for its binaries, changing stack size to 2048. Some kernels have bug preventing us from setting this size lower than 4096 (https://bugzilla.redhat.com/show_bug.cgi?id=1463241). This is a simple check to detect such kernels (normally /bin/true always successful and returns nothing) """ try: p = Popen(['/bin/true'], preexec_fn=_pre_exec_check) p.wait() except OSError as e: if getattr(e, 'strerror', '') == 'Argument list too long': return False raise # reraise if we face another error message return True def _install_binary(self, username, password, tmp_dir=None): self.logger.info('\tMaking %s executable', self.ACRONIS_BIN) self._exec(self.MAKE_EXECUTABLE_CMD.format(self.ACRONIS_BIN)) self.logger.info('\tInstalling binary') res = self._exec( self.ACRONIS_INSTALL_BIN_CMD.format( bin=self.ACRONIS_BIN, endpoint=self.server_url, login=username, password=password, optional=' --tmp-dir=' + tmp_dir if tmp_dir else '' )) return res @classmethod def is_agent_installed(cls): return os.path.exists(cls.UNINSTALL_BIN) def install_client(self, username, password, force=False, tmp_dir=None): self.logger.info('Checking if agent is installable') if not self._check_rlimit_stack(): raise Exception( "The backup agent cannot be installed on this system " "because of a bug in the current running kernel " "(see https://bugzilla.redhat.com/show_bug.cgi?id=1463241). " "Update kernel and try again. " "Use KernelCare to apply kernel updates without a reboot -- " "https://kernelcare.com/" ) self.logger.info('Searching for installed agents') installed = self.is_agent_installed() if installed and not force: raise ClientAlreadyInstalledError( 'Looks like Acronis backup is already installed. ' 'Use --force to override') deps = self.ACRONIS_DEPS kernel_dev_args = () if self._is_hybrid_kernel(): self.logger.info('Hybrid Kernel detected: installing devtoolset') deps, kernel_dev_args = self._enable_devtoolset_repo() self._enable_devtoolset_dkms() self.logger.info('Installing dependencies') try: self.pm.install(*deps) except package_manager.PackageManagerError as e: raise Exception( 'Error installing dependencies: {}'.format(e.stderr.strip()) ) self.logger.info('Kernel package installed: %s', self.pm.kernel_name_full) self.logger.info('Checking if %s is installed.', self.pm.kernel_dev_name_full) if not self.pm.is_kernel_dev_installed(): self.logger.info('Installing %s', self.pm.kernel_dev_name_full) try: self.pm.install_kernel_dev(*kernel_dev_args) except package_manager.PackageManagerError: raise Exception('{} package could not be found. ' 'Please install it manually or try with ' 'a different kernel.' .format(self.pm.kernel_dev_name_full)) self.logger.info('%s is installed.', self.pm.kernel_dev_name_full) self.logger.info('Downloading Acronis binary') self._download_binary() self.logger.info('Installing binary') ret, out = self._install_binary(username, password, tmp_dir) self.logger.info(out) if ret != 0: raise Exception('Error while installing binary: {}'.format(out)) def register(self, username, password): self.logger.info( 'Registering on {endpoint}'.format(endpoint=self.server_url) ) if os.path.exists(self.REG_BIN): reg_bin, reg_cmd = self.REG_BIN, self.REG_CMD elif os.path.exists(self.REG_BIN_OBSOLETE): reg_bin, reg_cmd = self.REG_BIN_OBSOLETE, self.REG_CMD_OBSOLETE else: raise Exception('No any register binary found') rc, out = self._exec(reg_cmd.format( bin=reg_bin, endpoint=self.server_url, login=username, password=password, )) if rc != 0: raise Exception('Error while registering: {}'.format(out)) self.logger.info('Registered successfully!') def _is_hybrid_kernel(self): return '.el6h.' in self.pm.kernel_ver def _enable_devtoolset_repo(self): yum_repo_file = Path(self.YUM_REPO_DIR) / Path(self.DTS_REPO_URL).name with yum_repo_file.open('wb+') as f: with closing(requests.get(self.DTS_REPO_URL, stream=True)) as r: r.raise_for_status() for content in r.iter_content(chunk_size=1024): f.write(content) return ( self.ACRONIS_DEPS + self.DTS_DEPS, ('--enablerepo', 'cloudlinux-hybrid-testing'), ) def _enable_devtoolset_dkms(self): dkms_config = Path(self.DKMS_CONFIG) config = [] if dkms_config.exists(): with dkms_config.open() as r: config = [ line for line in r.readlines() if not ( line.startswith("export PATH") and "devtoolset" in line ) ] config.append(self.DKMS_PATH_VAR.format(path=self.DTS_BIN_PATH)) dkms_config.parent.mkdir(parents=True, exist_ok=True) with dkms_config.open('w+') as w: w.writelines(config) @classmethod def uninstall(cls): cls.is_agent_installed() and run(cls.UNINSTALL_CMD) def _strip_device_name(path, patt=re.compile(r'^[A-Za-z][A-Za-z0-9]+?:')): """ Here we've to remove disk prefix (like 'vda5:') and optionally prepend forward slash if missing :param path: str -> path to handle :param patt: regex obj -> pattern to match against :return: str -> modified path """ path = patt.sub('', path) if '/' not in path: path = "/{}".format(path) return path def _pre_exec_check(): resource.setrlimit(resource.RLIMIT_STACK, (4096, 4096)) def _repeat(fn, *callee_args, **callee_kw): rv = None for i in range(REPEAT_NUM): rv = fn(*callee_args, **callee_kw) if rv: return rv logging.warning( "Callee %s returned falsy result. Attempt %d", fn.__name__, i) time.sleep(REPEAT_WAIT) return rv @from_env(username="ACCOUNT_NAME", password="PASSWORD") def init( username, password, provision=False, force=False, tmp_dir=None, ): AcronisConnector.create_token(username, password, force) server_url = AcronisConnector.get_server_url(username) installer = AcronisClientInstaller(server_url) if provision: installer.install_client(username, password, force, tmp_dir) elif installer.is_agent_installed(): installer.register(username, password) @asyncable def is_agent_installed(): return AcronisClientInstaller.is_agent_installed() def _wait_backup_running(acr): alerts = [] for a in range(1, BACKUP_ATTEMPTS + 1): logging.warning("Starting backup, attempt %s", a) AcronisAPI.make_initial_backup() # wait until start for i in range(1, REPEAT_NUM + 1): if acr.is_backup_running(): return logging.warning("Backup is not started after scheduling, " "attempt %s, waiting %s seconds", i, REPEAT_WAIT) time.sleep(REPEAT_WAIT) alerts = AcronisAPI().get_alerts(as_json=False) if alerts: break raise BackupFailed(alerts) @asyncable @auth_required @client_required @extra def make_initial_backup_strict(): """ Starts initial backup and waits until the backup will be started and completed and logs the progress to the file specified in AcronisAPI.backup_log_path. :raise BackupFailed: if backup not started of failed """ acr = AcronisAPI(subscribe=True) _wait_backup_running(acr) # log backup progress with open(acr.backup_log_path, 'w') as f: while acr.is_backup_running(): progress = acr.get_backup_progress() if progress: f.write('{}\n'.format(progress)) f.flush() time.sleep(5) if acr.get_backup_status() == 'error': alerts = AcronisAPI().get_alerts(as_json=False) raise BackupFailed(alerts) @asyncable @auth_required def make_initial_backup(trace=False): """ DEPRECATED Starts initial backup. If trace is True, the function waits until the backup is completed and logs the progress to the file specified in AcronisAPI.backup_log_path. Returns True if no errors occurred and False otherwise. If trace is False always returns None. """ AcronisAPI.make_initial_backup() if not trace: return None acr = AcronisAPI(subscribe=True) with open(acr.backup_log_path, 'w') as f: while acr.is_backup_running(): f.write('{}\n'.format(acr.get_backup_progress())) f.flush() time.sleep(5) return acr.get_backup_status() != 'error' @asyncable @auth_required @client_required def is_backup_running(): return AcronisAPI(subscribe=True).is_backup_running() @asyncable @auth_required @client_required def get_backup_progress(): return AcronisAPI(subscribe=True).get_backup_progress() @auth_required @client_required def backups(until=None, **__): return AcronisAPI.pull_backups(until=until) @asyncable @auth_required @extra def login_url(): return AcronisConnector().get_login_url() @auth_required @client_required def info(): return AcronisAPI().get_info() @auth_required @extra def refresh_token(): AcronisConnector().refresh_token() @auth_required @extra def migration_fix_backup_plan(): AcronisAPI.migration_fix_backup_plan() @asyncable @auth_required def get_alerts(as_json=True): return AcronisAPI().get_alerts(as_json) @extra def is_installed(): if AcronisClientInstaller.is_agent_installed(): return 'Acronis agent is installed' else: raise helpers.ActionError('Acronis agent is NOT installed!') @extra def uninstall(): AcronisClientInstaller.uninstall()