operations.py 11.1 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
# Copyright 2014 Budapest University of Technology and Economics (BME IK)
#
# This file is part of CIRCLE Cloud.
#
# CIRCLE is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# CIRCLE is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along
# with CIRCLE.  If not, see <http://www.gnu.org/licenses/>.

18
from inspect import getargspec
19 20
from logging import getLogger

21
from django.core.exceptions import PermissionDenied, ImproperlyConfigured
22
from django.utils.translation import ugettext_noop
23

24 25
from .models import (activity_context, has_suffix, humanize_exception,
                     HumanReadableObject)
26

27 28 29
logger = getLogger(__name__)


30
class SubOperationMixin(object):
31
    required_perms = ()
32 33 34 35 36 37 38 39 40

    def create_activity(self, parent, user, kwargs):
        if not parent:
            raise TypeError("SubOperation can only be called with "
                            "parent_activity specified.")
        return super(SubOperationMixin, self).create_activity(
            parent, user, kwargs)


41 42 43 44
class Operation(object):
    """Base class for VM operations.
    """
    async_queue = 'localhost.man'
45
    required_perms = None
46
    superuser_required = False
47
    do_not_call_in_templates = True
Kálmán Viktor committed
48
    abortable = False
49
    has_percentage = False
50

51 52 53
    @classmethod
    def get_activity_code_suffix(cls):
        return cls.id
Őry Máté committed
54

55 56 57 58 59 60 61 62 63 64 65 66 67 68
    def __call__(self, **kwargs):
        return self.call(**kwargs)

    def __init__(self, subject):
        """Initialize a new operation bound to the specified subject.
        """
        self.subject = subject

    def __unicode__(self):
        return self.name

    def __prelude(self, kwargs):
        """This method contains the shared prelude of call and async.
        """
69 70 71 72 73 74 75 76 77 78
        defaults = {'parent_activity': None, 'system': False, 'user': None}

        allargs = dict(defaults, **kwargs)  # all arguments
        auxargs = allargs.copy()  # auxiliary (i.e. only for _operation) args
        # NOTE: consumed items should be removed from auxargs, and no new items
        # should be added to it

        skip_auth_check = auxargs.pop('system')
        user = auxargs.pop('user')
        parent_activity = auxargs.pop('parent_activity')
79
        if parent_activity and user is None and not skip_auth_check:
80
            user = allargs['user'] = parent_activity.user
81 82
            if user is None:  # parent was a system call
                skip_auth_check = True
83 84 85 86

        # check for unexpected keyword arguments
        argspec = getargspec(self._operation)
        if argspec.keywords is None:  # _operation doesn't take ** args
87
            unexpected_kwargs = set(auxargs) - set(argspec.args)
88 89 90
            if unexpected_kwargs:
                raise TypeError("Operation got unexpected keyword arguments: "
                                "%s" % ", ".join(unexpected_kwargs))
91

92
        if not skip_auth_check:
93 94 95
            self.check_auth(user)
        self.check_precond()

96 97
        activity = self.create_activity(
            parent=parent_activity, user=user, kwargs=kwargs)
98 99 100 101

        return activity, allargs, auxargs

    def _exec_op(self, allargs, auxargs):
102 103
        """Execute the operation inside the specified activity's context.
        """
104 105 106 107 108 109 110 111 112 113
        # compile arguments for _operation
        argspec = getargspec(self._operation)
        if argspec.keywords is not None:  # _operation takes ** args
            arguments = allargs.copy()
        else:  # _operation doesn't take ** args
            arguments = {k: v for (k, v) in allargs.iteritems()
                         if k in argspec.args}
        arguments.update(auxargs)

        with activity_context(allargs['activity'], on_abort=self.on_abort,
114 115 116 117 118 119
                              on_commit=self.on_commit) as act:
            retval = self._operation(**arguments)
            if (act.result is None and isinstance(
                    retval, (basestring, int, HumanReadableObject))):
                act.result = retval
            return retval
120

121
    def _operation(self, **kwargs):
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137
        """This method is the operation's particular implementation.

        Deriving classes should implement this method.
        """
        raise NotImplementedError

    def async(self, **kwargs):
        """Execute the operation asynchronously.

        Only a quick, preliminary check is ran before creating the associated
        activity and queuing the job.

        The returned value is the handle for the asynchronous job.

        For more information, check the synchronous call's documentation.
        """
138 139 140
        logger.info("%s called asynchronously on %s with the following "
                    "parameters: %r", self.__class__.__name__, self.subject,
                    kwargs)
141 142 143 144
        activity, allargs, auxargs = self.__prelude(kwargs)
        return self.async_operation.apply_async(
            args=(self.id, self.subject.pk, activity.pk, allargs, auxargs, ),
            queue=self.async_queue)
145 146 147 148 149

    def call(self, **kwargs):
        """Execute the operation (synchronously).

        Anticipated keyword arguments:
150 151 152
        * parent_activity: Parent activity for the operation. If this argument
                           is present, the operation's activity will be created
                           as a child activity of it.
153 154 155
        * system: Indicates that the operation is invoked by the system, not a
                  User. If this argument is present and has a value of True,
                  then authorization checks are skipped.
156 157
        * user: The User invoking the operation. If this argument is not
                present, it'll be provided with a default value of None.
158
        """
159 160 161
        logger.info("%s called (synchronously) on %s with the following "
                    "parameters: %r", self.__class__.__name__, self.subject,
                    kwargs)
162 163 164
        activity, allargs, auxargs = self.__prelude(kwargs)
        allargs['activity'] = activity
        return self._exec_op(allargs, auxargs)
165 166 167 168

    def check_precond(self):
        pass

169 170 171 172 173 174
    @classmethod
    def check_perms(cls, user):
        """Check if user is permitted to run this operation on any instance
        """

        if cls.required_perms is None:
175 176
            raise ImproperlyConfigured(
                "Set required_perms to () if none needed.")
177
        if not user.has_perms(cls.required_perms):
178 179
            raise PermissionDenied(
                u"%s doesn't have the required permissions." % user)
180 181 182
        if cls.superuser_required and not user.is_superuser:
            raise humanize_exception(ugettext_noop(
                "Superuser privileges are required."), PermissionDenied())
183

184 185 186 187 188 189
    def check_auth(self, user):
        """Check if user is permitted to run this operation on this instance
        """

        self.check_perms(user)

190
    def create_activity(self, parent, user, kwargs):
191 192 193 194 195 196 197 198
        raise NotImplementedError

    def on_abort(self, activity, error):
        """This method is called when the operation aborts (i.e. raises an
        exception).
        """
        pass

199 200 201 202 203 204 205 206 207 208 209 210
    def get_activity_name(self, kwargs):
        try:
            return self.activity_name
        except AttributeError:
            try:
                return self.name._proxy____args[0]  # ewww!
            except AttributeError:
                raise ImproperlyConfigured(
                    "Set Operation.activity_name to an ugettext_nooped "
                    "string or a create_readable call, or override "
                    "get_activity_name to create a name dynamically")

211 212 213 214 215 216 217 218 219 220 221 222 223
    def on_commit(self, activity):
        """This method is called when the operation executes successfully.
        """
        pass


operation_registry_name = '_ops'


class OperatedMixin(object):
    def __getattr__(self, name):
        # NOTE: __getattr__ is only called if the attribute doesn't already
        # exist in your __dict__
224 225 226 227
        return self.get_operation_class(name)(self)

    @classmethod
    def get_operation_class(cls, name):
228 229 230
        ops = getattr(cls, operation_registry_name, {})
        op = ops.get(name)
        if op:
231
            return op
232 233
        else:
            raise AttributeError("%r object has no attribute %r" %
234
                                 (cls.__name__, name))
235

236 237 238 239
    def get_available_operations(self, user):
        """Yield Operations that match permissions of user and preconditions.
        """
        for name in getattr(self, operation_registry_name, {}):
240
            op = getattr(self, name)
241 242 243 244 245 246 247 248
            try:
                op.check_auth(user)
                op.check_precond()
            except:
                pass  # unavailable
            else:
                yield op

249
    def get_operation_from_activity_code(self, activity_code):
250 251 252 253 254 255 256
        """Get an instance of the Operation corresponding to the specified
           activity code.

        :returns: A bound instance of an operation, or None if no matching
                  operation could be found.
        """
        for op in getattr(self, operation_registry_name, {}).itervalues():
257
            if has_suffix(activity_code, op.get_activity_code_suffix()):
258
                return op(self)
259 260 261
        else:
            return None

262

263
def register_operation(op_cls, op_id=None, target_cls=None):
264 265 266 267 268 269
    """Register the specified operation with the target class.

    You can optionally specify an ID to be used for the registration;
    otherwise, the operation class' 'id' attribute will be used.
    """
    if op_id is None:
270 271 272 273 274 275 276 277
        try:
            op_id = op_cls.id
        except AttributeError:
            raise NotImplementedError("Operations should specify an 'id' "
                                      "attribute designating the name the "
                                      "operation can be called by on its "
                                      "host. Alternatively, provide the name "
                                      "in the 'op_id' parameter to this call.")
278

279 280 281 282 283 284 285 286 287 288 289
    if target_cls is None:
        try:
            target_cls = op_cls.host_cls
        except AttributeError:
            raise NotImplementedError("Operations should specify a 'host_cls' "
                                      "attribute designating the host class "
                                      "the operation should be registered to. "
                                      "Alternatively, provide the host class "
                                      "in the 'target_cls' parameter to this "
                                      "call.")

290 291
    assert not hasattr(target_cls, op_id), (
        "target class already has an attribute with this id")
292 293 294 295 296 297 298 299
    if not issubclass(target_cls, OperatedMixin):
        raise TypeError("%r is not a subclass of %r" %
                        (target_cls.__name__, OperatedMixin.__name__))

    if not hasattr(target_cls, operation_registry_name):
        setattr(target_cls, operation_registry_name, dict())

    getattr(target_cls, operation_registry_name)[op_id] = op_cls
300
    return op_cls