HOME


Mini Shell 1.0
DIR: /opt/cloudlinux/venv/lib64/python3.11/site-packages/xray/adviser/
Upload File :
Current File : //opt/cloudlinux/venv/lib64/python3.11/site-packages/xray/adviser/cli_api.py
# -*- coding: utf-8 -*-

# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2021 All Rights Reserved
#
# Licensed under CLOUD LINUX LICENSE AGREEMENT
# http://cloudlinux.com/docs/LICENSE.TXT

"""
This module contains X Ray Smart Advice local utility main class
"""
import json
import logging
import os
import hashlib
import pwd
import subprocess
from contextlib import contextmanager
from dataclasses import asdict, dataclass
from typing import Any, Tuple, Optional, List
from enum import Enum

from clwpos.user.config import LicenseApproveStatus
from schema import Schema, SchemaError

from clwpos.papi import (
    is_feature_visible,
    is_feature_allowed,
    is_subscription_pending,
    get_subscription_upgrade_url,
    get_license_approve_status,
    approve_license_agreement,
    get_license_agreement_text,
    get_subscriptions_info,
    calculate_cdn_usage,
    get_user_auth_key,
    is_feature_hidden_server_wide,
    is_smart_advice_notifications_disabled_server_wide,
    is_smart_advice_reminders_disabled_server_wide
)
from clcommon.cpapi import (
    docroot,
    is_panel_feature_supported,
    get_user_emails_list,
    panel_awp_link,
    getCPName,
    userdomains
)
from clcommon.clwpos_lib import is_wp_path
from clcommon.clpwd import drop_privileges
from clcommon.const import Feature
from .advice_types import advice_mapping
from .advice_types.wpos_base import WPOSModuleApply
from .progress import SmartAdviceProgress
from .schemas import (
    advice_list_schema,
    detailed_advice_schema,
    user_sites_info_schema
)
from ..apiclient import get_client
from ..internal.constants import advice_pending_storage, advice_processed_storage, advice_list_cache, advice_reason_max_len
from ..internal.exceptions import XRayAPIError, XRayError, SmartAdvicePluginError
from ..internal.user_plugin_utils import user_mode_advice_verification, username_verification, get_xray_exec_user
from ..internal.utils import timestamp, safe_move, get_user_php_version, filelock
from ..analytics.utils import report_usage_action_or_error

advice_cache_separator = ';'


class AdviceActions(Enum):
    APPLY = 'apply'
    ROLLBACK = 'rollback'


@dataclass
class SmartAdviceOptions:
    panel_type: str
    panel_url: str
    panel_emails: str
    upgrade_url: str
    upgrade_url_cdn: str
    subscription: dict
    notifications: dict


class SmartAdviceUtil:
    """Main Smart Advice local utility class"""

    def __init__(self):
        self.logger = logging.getLogger('smart_advice')
        # check existence of pending and apply storage and create it if missing
        for stor in (advice_pending_storage, advice_processed_storage):
            self.create_dir(stor)
        # initialize Adviser API client
        adviser_client_object = get_client('adviser')
        self.adviser_client = adviser_client_object()

    @staticmethod
    def create_dir(dpath: str) -> None:
        """Create dir if missing"""
        if not os.path.isdir(dpath):
            os.mkdir(dpath)

    @staticmethod
    def response(**kwargs) -> str:
        """
        Create JSON response message with result field == success
        and given keyword arguments in other fields
        :return: json packed string
        """
        initial = {'result': 'success', 'timestamp': timestamp()}
        if kwargs:
            initial.update(kwargs)
        return json.dumps(dict(sorted(initial.items())))

    @staticmethod
    def _apply_datafile(a_id: int) -> str:
        """Per-advice file with results of apply"""
        return f'{advice_processed_storage}/{a_id}'

    def _apply_results(self, a_id: int) -> Optional[str]:
        """Retrieve data stored in per-advice file with results of apply"""
        datafile = self._apply_datafile(a_id)
        if os.path.isfile(datafile):
            try:
                with open(datafile) as _f:
                    data = json.load(_f)
            except OSError:
                data = None
            except json.JSONDecodeError:
                with open(datafile) as _f:
                    data = _f.read()
            finally:
                os.unlink(datafile)
            return data

    @staticmethod
    def _pending_flag(a_id: int) -> str:
        """Per-advice pending flag"""
        return f'{advice_pending_storage}/{a_id}'

    def is_advice_pending(self, advice_id: int) -> bool:
        """Is advice in pending state"""
        return os.path.exists(self._pending_flag(advice_id))

    def _progress_file(self, a_id: int) -> str:
        """Per-advice progress storage"""
        return self._pending_flag(a_id) + '.progress'

    def _get_user_awp_link(self, username):
        try:
            return panel_awp_link(username)
        except Exception:
            self.logger.exception('Error while getting user login link')
            return ''

    @staticmethod
    def get_advice_instance(advice_type: str) -> WPOSModuleApply:
        """Get appropriate instance of Apply actions class by advice type"""
        return advice_mapping[advice_type]

    def _validate(self, data: Any, schema: Schema) -> Any:
        """Validate given data using given schema"""
        try:
            return schema.validate(data)
        except SchemaError as e:
            self.logger.error('Failed to validate API response: %s', data)
            msg = e.errors[-1] or e.autos[-1]
            raise XRayAPIError(f'Malformed API response: {msg}')

    @user_mode_advice_verification
    def get_detailed_advice(self, advice_id: int) -> Tuple[dict, object]:
        """
        Get advice details from API along with an
        appropriate Advice object by obtained advice type
        """
        _response = self._validate(
            data=self.adviser_client.advice_details(advice_id),
            schema=detailed_advice_schema)
        return _response, self.get_advice_instance(_response['advice']['type'])

    @staticmethod
    def dump_to_file(dst: str, data: Any, as_json: bool = False) -> None:
        """Dump data inside given dst using .bkp file and then move"""
        _tmp_dst = dst + '.bkp'
        with open(_tmp_dst, 'w') as _f:
            if as_json:
                try:
                    json.dump(data, _f)
                except TypeError:
                    _f.write(data)
            else:
                _f.write(data)
        safe_move(_tmp_dst, dst)

    def progress(self, advice_id: int, current: SmartAdviceProgress) -> SmartAdviceProgress:
        """
        Smart Advice apply own progress,
        based on current and previous results of 'clwpos get-progress'.
        Returns the maximum progress values among current and previous.
        Previous (latest) is stored inside a pending file-flag.
        """

        @contextmanager
        def resolve_progress() -> SmartAdviceProgress:
            """
            Get progress stored in file and return the maximum one among
            current and stored results
            """
            self.logger.debug('Current progress value: %s', current)
            progress_dst = self._progress_file(advice_id)
            try:
                # read stored result
                with open(progress_dst) as prev:
                    prev_stages = SmartAdviceProgress(**json.load(prev))
            except (OSError, json.JSONDecodeError) as e:
                self.logger.debug('Error during reading stored value: %s',
                                  str(e))
                # or set a dummy one
                prev_stages = SmartAdviceProgress()

            self.logger.debug('Stored progress value: %s', prev_stages)
            yield current if current > prev_stages else prev_stages

            if self.is_advice_pending(advice_id) and current > prev_stages:
                # update stored result only if pending file exists
                self.logger.debug('Updating stored progress: %s', current)
                self.dump_to_file(progress_dst, asdict(current),
                                  as_json=True)

        return resolve_progress()

    def get_current_status(self, _id: int, advice_obj: object,
                           advice_data: dict) -> Tuple[str, dict]:
        """
        Resolve if advice is in pending status.
        Progress is retrieved for pending advice only.
        Dummy result (0, 0) is returned for other advice states.
        """
        if self.is_advice_pending(_id):
            status = 'pending'
            with self.progress(_id, advice_obj.get_progress(
                    advice_data['metadata']['username'])) as p:
                stages = p
        else:
            status = advice_data['advice']['status']
            stages = SmartAdviceProgress()
        return status, asdict(stages)

    def advice_list(self, extended=False) -> str:
        """Load validated advice list and update it before returning"""
        api_response = self._validate(data=self.adviser_client.advice_list(),
                                      schema=advice_list_schema)

        response_advice_list = self.prepare_advices_response(api_response, extended=extended)

        # cl-smart-advice plugin syncing should not break whole command
        try:
            self.sync_advices_wordpress_plugin(current_advices=api_response)
        except Exception:
            self.logger.exception('Unable to sync cl-smart-advice plugin during getting list of advices')

        return self.response(data=response_advice_list)

    @username_verification
    def get_site_statuses(self, username) -> str:
        api_response = self._validate(data=self.adviser_client.site_info(username),
                                      schema=user_sites_info_schema)
        site_statuses_data = {}
        for website in api_response:
            domain = website['domain']
            if domain not in site_statuses_data:
                site_statuses_data[domain] = []
            site_statuses_data[domain].append({
                'website': website['website'],
                'urls_scanned': len(website['urls']),
                'advices': self.prepare_advices_response(website['advices'])
            })
        result = [{'domain': domain, 'websites': websites} for domain, websites in site_statuses_data.items()]
        return self.response(data=result)

    def _does_user_exist_on_server(self, username):
        try:
            pwd.getpwnam(username)
            return True
        except KeyError:
            logging.info('User %s does not exist anymore on this server, skipping advices for him')
            return False

    def prepare_advices_response(self, from_api, status_from_microservice=False, extended=False):
        advices = from_api.copy()

        visible_advices = []
        for item in advices:
            # skip advices which appear to be linked with non existing users
            if not self._does_user_exist_on_server(item['metadata']['username']):
                continue

            advice_itself = item['advice']
            advice_type = advice_itself['type']
            advice_instance = self.get_advice_instance(advice_type)
            advice_itself['description'] = advice_instance.short_description

            # when we need real response from microservice - only before sending json to wp plugin
            if status_from_microservice:
                status = advice_itself['status']
                stages = {'total_stages': 0, 'completed_stages': 0}
            else:
                status, stages = self.get_current_status(advice_itself['id'],
                                                         advice_instance, item)
            if extended:
                advice_itself['detailed_description'] = advice_instance.detailed_description

            advice_itself['status'] = status
            advice_itself['is_premium'] = advice_instance.is_premium_feature
            advice_itself['module_name'] = advice_instance.module_name

            advice_itself['license_status'] = get_license_approve_status(
                advice_instance.module_name, item['metadata']['username']).name

            if advice_itself['is_premium']:
                subscription_status = self._get_subscription_status(
                    advice_instance.module_name, item['metadata']['username'])
                advice_itself['subscription'] = dict(
                    status=subscription_status,
                    upgrade_url=get_subscription_upgrade_url(
                        advice_instance.module_name, item['metadata']['username'])
                )
            advice_itself.update(stages)

            if is_feature_visible(advice_instance.module_name,
                                  item['metadata']['username']) and \
                    not is_feature_hidden_server_wide(advice_instance.module_name):
                visible_advices.append(item)
        return visible_advices

    def _get_subscription_status(self, module_name, username):
        """
        Determines current subscription status based on feature status.
        """
        subscription_status = 'no'
        if is_feature_allowed(module_name, username):
            subscription_status = 'active'
        if is_subscription_pending(module_name, username):
            subscription_status = 'pending'
        return subscription_status

    def advice_details(self, advice_id: int) -> str:
        """Load validated advice details and update it before returning"""
        api_response, advice_instance = self.get_detailed_advice(advice_id)

        advice_itself = api_response['advice']

        """
        The microservice responds with field names: title and description.
        We must assign:
        [local advice] detailed_description = [microservice] description
        [local advice] description          = [microservice] title
        """

        # Until we won't rename detailed_description to description
        if advice_itself['description']:
            advice_itself['detailed_description'] = advice_itself['description']
        else:
            advice_itself['detailed_description'] = advice_instance.detailed_description

        # Until we won't rename description to title
        if advice_itself['title']:
            advice_itself['description'] = advice_itself['title']
        else:
            advice_itself['description'] = advice_instance.short_description
        del advice_itself['title']

        return self.response(data=api_response)

    def manage_advice(self, action, advice_id: int, ignore_errors: bool = False,
                      async_mode: bool = False, source: str = 'ACCELERATE_WP',
                      reason: str = None, accept_terms: bool = False,
                      analytics_data: str = None) -> Optional[str]:

        open(self._pending_flag(advice_id), 'w').close()

        if async_mode:
            # put itself to background in async mode
            # and return current status
            child = os.fork()
            if child:
                return self.manage_advice_status(advice_id)
        output = self._exec_advice_managing(action, advice_id, ignore_errors, async_mode,
                                            source, reason, accept_terms)
        if analytics_data is not None:
            report_usage_action_or_error(analytics_data, advice_id, source, output, action)
        if async_mode:
            # retrun no output in async mode
            output = None
        return output

    def _exec_advice_managing(self, action: str, advice_id: int, ignore_errors: bool = False,
                              async_mode: bool = False, source: str = 'ACCELERATE_WP',
                              reason: str = None, accept_terms: bool = False) -> Optional[str]:
        """Execute managing advice with passed action: apply/rollback"""
        try:
            api_response, advice_instance = self.get_detailed_advice(advice_id)

            if action == AdviceActions.APPLY.value:
                if accept_terms:
                    approve_license_agreement(
                        advice_instance.module_name, api_response['metadata']['username'])

                if get_license_approve_status(
                        advice_instance.module_name,
                        api_response['metadata']['username']) == LicenseApproveStatus.NOT_APPROVED:
                    return json.dumps({
                        'result': 'LICENCE_TERMS_APPROVE_REQUIRED',
                        'text': 'License approve required to use this feature. '
                                'Open AccelerateWP plugin in your control panel, apply advice and '
                                'accept terms and conditions to proceed with installation.'
                    })
                action_result, output = advice_instance.apply(**api_response['metadata'],
                                                              ignore_errors=ignore_errors)
                if action_result:
                    self.logger.debug('Applied successfully')
                    self.adviser_client.update_advice(advice_id=advice_id,
                                                      status='applied',
                                                      source=source)

                    try:
                        self._sync_advice(api_response['metadata']['username'],
                                          api_response['metadata']['domain'],
                                          api_response['metadata']['website'])
                    except Exception:
                        self.logger.exception('Error while syncing cl-smart-advice plugin on advice apply')

            elif action == AdviceActions.ROLLBACK.value:
                action_result, output = advice_instance.rollback(**api_response['metadata'])
                if action_result:
                    self.logger.debug('Rollback successfully')
                    if reason:
                        self.adviser_client.update_advice(advice_id=advice_id,
                                                          status='review',
                                                          source=source,
                                                          reason=reason[:advice_reason_max_len])
                    else:
                        self.adviser_client.update_advice(advice_id=advice_id,
                                                          status='review',
                                                          source=source)

                    try:
                        self._sync_advice(api_response['metadata']['username'],
                                          api_response['metadata']['domain'],
                                          api_response['metadata']['website'])
                    except Exception:
                        self.logger.exception('Error while syncing cl-smart-advice plugin on advice rollback')
            else:
                raise ValueError(f'Unsupported action with advice, passed action: {action}')

        finally:
            if os.path.isfile(self._pending_flag(advice_id)):
                os.unlink(self._pending_flag(advice_id))

        if async_mode:
            # in async mode write result to a special file
            self.dump_to_file(self._apply_datafile(advice_id),
                              output)
            # return output for analytics will be cleared after reporting
            return output
        else:
            # in sync mode return output
            return output

    def advice_counters(self) -> str:
        """Return advice counters for a server"""
        try:
            api_response = self._validate(data=self.adviser_client.advice_list(),
                                          schema=advice_list_schema)
        except XRayError:
            # bad API responses (500 for example)
            # or JWT token failed check (CL Shared for example)
            result = dict.fromkeys(['total', 'applied'], None)
        else:
            result = dict(
                total=len(api_response),
                applied=len([advice for advice in api_response if
                             advice['advice']['status'] == 'applied'])
            )
        return self.response(data=result)

    def manage_advice_status(self, advice_id: int) -> str:
        """
        Return current status and progress of managing particular advice.
        """
        api_response, advice_instance = self.get_detailed_advice(advice_id)
        status, stages = self.get_current_status(advice_id, advice_instance,
                                                 api_response)

        subscription_status = self._get_subscription_status(
            advice_instance.module_name, api_response['metadata']['username'])

        if status != 'pending':
            # include result of apply/rollback for non-pending advice only
            result = self._apply_results(advice_id)
            # drop stored progress
            _progress = self._progress_file(advice_id)
            if os.path.isfile(_progress):
                os.unlink(_progress)

            if result:
                return self.response(
                    status=status,
                    subscription=dict(
                        object_cache=subscription_status
                    ),
                    upgrade_url=get_subscription_upgrade_url(
                        advice_instance.module_name,
                        api_response['metadata']['username']),
                    **stages,
                    data=result)

        return self.response(
            status=status,
            subscription=dict(
                object_cache=subscription_status
            ),
            upgrade_url=get_subscription_upgrade_url(
                advice_instance.module_name,
                api_response['metadata']['username']),
            **stages
        )

    def _sync_advice(self, username, domain, website):
        current_advices = self._validate(data=self.adviser_client.advice_list(),
                                         schema=advice_list_schema)
        current_advices_by_site = self.group_alive_advice_ids_by_website(current_advices)
        try:
            self._run_smart_advice_script(username, website, domain,
                                          self.prepare_advices_response(current_advices,
                                                                        status_from_microservice=True, extended=True))
        except Exception:
            self.logger.exception('Smart Advice plugin sync failed for website: %s', str(website))
            return

        cached_advices_by_site = self._read_advices_cache()
        key = f'{domain}{advice_cache_separator}{username}{advice_cache_separator}{website}'
        if key not in cached_advices_by_site:
            cached_advices_by_site[key] = {}
        cached_advices_by_site[key] = current_advices_by_site[key]
        self._update_advices_cache(cached_advices_by_site)

    def _get_cdn_usage_statistics(self, username):
        from xray.adviser.awp_provision_api import AWPProvisionAPI
        acc_id = get_user_auth_key(username)
        cdn_usage = AWPProvisionAPI().awp_client_api.get_usage(acc_id)
        return cdn_usage

    @username_verification
    def get_options(self, username):
        """
        gets options:
        "panel_type": "cpanel",
        "panel_url": "https:/cpanel-foo.com/", # or https://plesk-bar.com/
        "panel_emails": "root@hosting.com,manager@hostring.com,foo@bar.com",
        "upgrade_url": "https://...",
        "upgrade_url_cdn": "https://...",
        "subscription": dict,
        "notifications": dict,
        """
        try:
            # [("domain.com"),("/path/to/docroot")]
            domain = userdomains(username)[0][0]
            email = get_user_emails_list(username, domain)
        except Exception:
            self.logger.exception('Unable to get user emails')
            email = ''
        try:
            subscription = get_subscriptions_info(username)
        except Exception:
            self.logger.exception('Cannot obtain subscription info')
            subscription = {}

        notifications = dict(
            email_status="disabled_server" if is_smart_advice_notifications_disabled_server_wide() is True else "enabled",
            reminders_status="disabled_server" if is_smart_advice_reminders_disabled_server_wide() is True else "enabled",
        )

        return self.response(**asdict(
            SmartAdviceOptions(panel_type=getCPName(),
                               panel_url=self._get_user_awp_link(username),
                               panel_emails=email,
                               upgrade_url=get_subscription_upgrade_url('object_cache', username),
                               upgrade_url_cdn=get_subscription_upgrade_url('cdn', username),
                               subscription=subscription,
                               notifications=notifications)
        ))

    def sync_advices_wordpress_plugin(self, current_advices=None):
        if not current_advices:
            current_advices = self._validate(data=self.adviser_client.advice_list(),
                                             schema=advice_list_schema)
        current_advices_by_site = self.group_alive_advice_ids_by_website(current_advices)

        if not current_advices_by_site:
            return self.response(data='No advices found on the server')

        cached_advices_by_site = self._read_advices_cache()

        updated_cache = self._sync_sa_plugin(current_advices_by_site, cached_advices_by_site,
                                             current_advices_json=self.prepare_advices_response(current_advices,
                                                                                                extended=True))
        self._update_advices_cache(updated_cache)
        return self.response(data='Plugin installed')

    def _sync_sa_plugin(self, current_advices, cached_advices, current_advices_json=None):
        filtered_advices_by_user = None
        updated_advices = {}
        for key, id_status_hash in current_advices.items():
            (domain, username, website) = key.split(advice_cache_separator)
            if not self._should_sync_website(id_status_hash, cached_advices.get(key)):
                updated_advices[key] = id_status_hash
                continue

            # keep only current user advices
            if current_advices_json:
                filtered_advices_by_user = [advice for advice in current_advices_json
                                            if advice.get('metadata', {}).get('username') == username]

            try:
                self._run_smart_advice_script(username, website, domain, filtered_advices_by_user)
            except Exception:
                self.logger.exception('Smart advice plugin sync failed for website: %s', str(website))
                continue

            updated_advices[key] = id_status_hash
        return updated_advices

    @staticmethod
    def _update_advices_cache(new_cached_advices):
        with open(f'{advice_list_cache}.lock', 'a+') as lock_fd:
            with filelock(lock_fd):
                with open(advice_list_cache, 'w') as f:
                    json.dump(new_cached_advices, f, indent=2)

    def _read_advices_cache(self):
        cached_advices_by_site = {}
        if os.path.exists(advice_list_cache):
            try:
                with open(advice_list_cache) as f:
                    cached_advices_by_site = json.load(f)
            except Exception:
                self.logger.exception('Unable to read advices cache json')
                cached_advices_by_site = {}
        return cached_advices_by_site

    @staticmethod
    def _should_sync_website(current_hash, cached_hash):
        return current_hash != cached_hash

    @staticmethod
    def make_hash(id_status_pairs):
        h = hashlib.md5()
        for advice_id, status in id_status_pairs:
            h.update(str(advice_id).encode())
            h.update(status.encode())
        return h.hexdigest()

    def group_alive_advice_ids_by_website(self, advices):
        websites = {}
        for advice in advices:
            metadata = advice['metadata']
            advice_data = advice['advice']
            advice_instance = self.get_advice_instance(advice_data['type'])
            module_name = advice_instance.module_name

            if is_feature_hidden_server_wide(module_name):
                continue

            key = f'{metadata["domain"]}{advice_cache_separator}{metadata["username"]}' \
                  f'{advice_cache_separator}{metadata["website"]}'
            if key not in websites:
                websites[key] = []
            websites[key].append((advice_data['id'], advice_data['status']))
        return {
            key: self.make_hash(tuple(sorted(id_status_pairs, key=lambda item: item[0])))
            for key, id_status_pairs in websites.items()
        }

    @staticmethod
    def _save_custom_php(path, content):
        """
        For easy mocking
        """
        with open(path, 'w') as f:
            f.write(content)

    def _prepare_php_for_plesk(self, username, domain):
        clwpos_dir = f'{pwd.getpwnam(username).pw_dir}/.clwpos'
        custom_php = '.php-ini'
        custom_php_full = os.path.join(clwpos_dir, custom_php)
        extra_paths = [
            '/opt/alt/php-xray/',
            '/opt/cloudlinux-site-optimization-module/',
            '/opt/cloudlinux/'
        ]
        updated_setting = None

        open_basedir_setting = subprocess.run(f'/usr/sbin/plesk bin site --show-php-settings {domain} | /usr/bin/grep open_basedir',
                                              text=True, capture_output=True, shell=True)

        open_basedir_setting_sanitized = open_basedir_setting.stdout.replace('open_basedir =', '').replace('open_basedir=', '').strip()
        self.logger.info('Current open_basedir settings: %s', str(open_basedir_setting.stdout))

        # empty string means no restrictions; no need to add the extra paths
        if len(open_basedir_setting_sanitized) == 0:
            updated_setting = 'open_basedir ='
        # non-empty string and not "none"
        elif len(open_basedir_setting_sanitized) > 0 and open_basedir_setting_sanitized != 'none':
            # if 'none' is in the list of values obtained by splitting the string
            if 'none' in open_basedir_setting_sanitized.split(':'):
                updated_setting = 'open_basedir = none'
            elif open_basedir_setting_sanitized.startswith(':'):
                updated_setting = 'open_basedir = {WEBSPACEROOT}{/}{:}{TMP}{/}:' + ":".join(extra_paths)
            else:
                # collect missing extra paths
                missing_paths = []
                for extra_path in extra_paths:
                    # extra path not present
                    if extra_path not in open_basedir_setting.stdout:
                        missing_paths.append(extra_path)
                # some extra paths are missing, settings update is required
                if missing_paths:
                    updated_setting = f'open_basedir = {open_basedir_setting_sanitized}:{":".join(missing_paths)}'

        if updated_setting:
            self.logger.info('Updating open_basedir setting: %s for domain: %s', updated_setting, domain)
            # Both folder for custom PHP ini file and the file itself should be owned by appropriate user. We don't want
            # to leave files owned by root in user's home directory.
            with drop_privileges(username):
                if not os.path.exists(custom_php_full):
                    if not os.path.isdir(clwpos_dir):
                        os.mkdir(clwpos_dir, mode=0o700)
                self._save_custom_php(custom_php_full, updated_setting)

            result = subprocess.run(['/usr/sbin/plesk', 'bin', 'site', '--update-php-settings', domain, '-settings',
                            custom_php_full],
                           text=True, capture_output=True)
            self.logger.info('Plesk settings update result: %s', str(result.stdout))
            self.logger.error('open_basedir setting updated for domain "%s" from "%s" to "%s"',
                              str(domain),
                              str(open_basedir_setting.stdout),
                              str(updated_setting))

    def _run_smart_advice_script(self, username, website, domain, advices_json=None):
        if advices_json is None:
            json_string = self.response(data=[])
        else:
            json_string = self.response(data=advices_json)

        try:
            attrs = self._prepare_smart_advice_script_args(username, website, domain, json_string)
        except ValueError:
            return

        command = self._get_smart_advice_script_cmd(username, attrs)
        result = subprocess.run(command, text=True, capture_output=True)
        if result.returncode != 0:
            self.logger.error('Smart advice plugin failed with exit code: %s, stdout: %s, stderr: %s',
                              str(result.returncode),
                              str(result.stdout),
                              str(result.stderr),
                              extra={
                                'fingerprint': f'smart-advice-plugin-sh-returncode-{result.returncode}',
                              })
            raise SmartAdvicePluginError('Smart Advice plugin installation failed')

    def _prepare_smart_advice_script_args(self, username, website, domain: List[str], advices_json: str) -> List[str]:
        panel = getCPName()
        script_name = 'run.sh'
        script_path = f'/opt/alt/php-xray/php/smart-advice-plugin/{script_name}'
        panel_user_awp_link = self._get_user_awp_link(username)

        self.logger.info('PANEL AWP LINK %s', panel_user_awp_link)

        try:
            email = get_user_emails_list(username, domain)
        except Exception:
            self.logger.exception('Unable to get user emails')
            email = ''

        full_website_path = docroot(domain)[0] + website

        if not is_wp_path(full_website_path):
            self.logger.info('[Smart Advice Plugin]: wordpress: %s does not exist, skipped',
                             full_website_path)
            raise ValueError('Non-existing wordpress site')

        php_info = get_user_php_version(username)

        for php_data in php_info:
            if php_data.get('vhost') == domain:
                php_binary_path = php_data.get('php_binary')
                break
        else:
            self.logger.error('Php data for domain %s was not found in %s', domain, str(php_info))
            raise SmartAdvicePluginError(f'Unable to get php version for domain {domain}')

        if not php_binary_path:
            self.logger.error('Php version for user: %s was not obtained',
                              username)
            raise SmartAdvicePluginError(f'Php binary was not identified for domain {domain}')

        if panel == 'Plesk':
            try:
                self._prepare_php_for_plesk(username, domain)
            except Exception:
                self.logger.exception('Setting up php for Plesk for SA plugin failed')

        res = [
            script_path,
            php_binary_path,
            full_website_path,
            advices_json,
            email,
            panel_user_awp_link,
        ]
        return res

    @staticmethod
    def _get_smart_advice_script_cmd(username: str, attrs: List[str]) -> List[str]:
        if is_panel_feature_supported(Feature.CAGEFS):
            res = ['/sbin/cagefs_enter_user', username, '/bin/bash']
            res.extend(attrs)
        else:
            attrs[3] = "'" + attrs[3] + "'"   # escape json string
            command_str = '/bin/bash ' + ' '.join(attrs)
            res = ['sudo', '-u', username, '-s', '/bin/bash', '-c', command_str]
        return res

    def get_agreement_text(self, feature):
        """
        Method with handles work with agreements.
        """
        user_context = get_xray_exec_user()
        if not user_context:
            raise SmartAdvicePluginError('This command can only be executed as user')

        return json.dumps({
            'result': 'success',
            'timestamp': timestamp(),
            'text': get_license_agreement_text(feature, user_context)
        })

    def create_subscription(self, advice_id):
        """
        Proxy method to AWP which creates subscription
        listening for the status of module and automatic advice appy.
        """
        user_context = get_xray_exec_user()
        if not user_context:
            raise SmartAdvicePluginError('This command can only be executed as user')

        _, advice_instance = self.get_detailed_advice(advice_id)

        result = subprocess.run([
            'cloudlinux-awp-user',
            '--user', str(user_context),
            'subscription',
            '--listen',
            '--advice-id', str(advice_id),
            '--feature', advice_instance.module_name
        ], text=True, capture_output=True)

        if result.returncode != 0:
            self.logger.error('Create subscription smart advice plugin failed with exit code: '
                              '%s, stdout: %s, stderr: %s',
                              str(result.returncode),
                              str(result.stdout),
                              str(result.stderr),
                              extra={
                                  'fingerprint': f'smart-advice-plugin-subscription-returncode-{result.returncode}',
                              })
            raise SmartAdvicePluginError('cloudlinux-awp-user call failed %s' % result.stderr)