Statistics
| Branch: | Tag: | Revision:

root / snf-cyclades-app / synnefo / quotas / __init__.py @ 0f4c5b34

History | View | Annotate | Download (13.1 kB)

1
# Copyright 2012, 2013 GRNET S.A. All rights reserved.
2
#
3
# Redistribution and use in source and binary forms, with or without
4
# modification, are permitted provided that the following conditions
5
# are met:
6
#
7
#   1. Redistributions of source code must retain the above copyright
8
#      notice, this list of conditions and the following disclaimer.
9
#
10
#  2. Redistributions in binary form must reproduce the above copyright
11
#     notice, this list of conditions and the following disclaimer in the
12
#     documentation and/or other materials provided with the distribution.
13
#
14
# THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND
15
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
17
# ARE DISCLAIMED.  IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
18
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
19
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
20
# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
21
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
22
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
23
# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
24
# SUCH DAMAGE.
25
#
26
# The views and conclusions contained in the software and documentation are
27
# those of the authors and should not be interpreted as representing official
28
# policies, either expressed or implied, of GRNET S.A.
29

    
30
from django.utils import simplejson as json
31
from django.db import transaction
32

    
33
from snf_django.lib.api import faults
34
from synnefo.db.models import (QuotaHolderSerial, VirtualMachine, Network,
35
                               IPAddress)
36

    
37
from synnefo.settings import (CYCLADES_SERVICE_TOKEN as ASTAKOS_TOKEN,
38
                              ASTAKOS_AUTH_URL)
39
from astakosclient import AstakosClient
40
from astakosclient import errors
41

    
42
import logging
43
log = logging.getLogger(__name__)
44

    
45

    
46
DEFAULT_SOURCE = 'system'
47
RESOURCES = [
48
    "cyclades.vm",
49
    "cyclades.total_cpu",
50
    "cyclades.cpu",
51
    "cyclades.disk",
52
    "cyclades.total_ram",
53
    "cyclades.ram",
54
    "cyclades.network.private",
55
    "cyclades.floating_ip",
56
]
57

    
58

    
59
class Quotaholder(object):
60
    _object = None
61

    
62
    @classmethod
63
    def get(cls):
64
        if cls._object is None:
65
            cls._object = AstakosClient(ASTAKOS_TOKEN,
66
                                        ASTAKOS_AUTH_URL,
67
                                        use_pool=True,
68
                                        retry=3,
69
                                        logger=log)
70
        return cls._object
71

    
72

    
73
class AstakosClientExceptionHandler(object):
74
    def __init__(self, *args, **kwargs):
75
        pass
76

    
77
    def __enter__(self):
78
        pass
79

    
80
    def __exit__(self, exc_type, value, traceback):
81
        if value is not None:  # exception
82
            if not isinstance(value, errors.AstakosClientException):
83
                return False  # reraise
84
            if exc_type is errors.QuotaLimit:
85
                msg, details = render_overlimit_exception(value)
86
                raise faults.OverLimit(msg, details=details)
87

    
88
            log.exception("Unexpected error %s" % value.message)
89
            raise faults.InternalServerError("Unexpected error")
90

    
91

    
92
def issue_commission(resource, action, name="", force=False, auto_accept=False,
93
                     action_fields=None):
94
    """Issue a new commission to the quotaholder.
95

96
    Issue a new commission to the quotaholder, and create the
97
    corresponing QuotaHolderSerial object in DB.
98

99
    """
100

    
101
    provisions = get_commission_info(resource=resource, action=action,
102
                                     action_fields=action_fields)
103

    
104
    if provisions is None:
105
        return None
106

    
107
    user = resource.userid
108
    source = DEFAULT_SOURCE
109

    
110
    qh = Quotaholder.get()
111
    if True:  # placeholder
112
        with AstakosClientExceptionHandler():
113
            serial = qh.issue_one_commission(user, source,
114
                                             provisions, name=name,
115
                                             force=force,
116
                                             auto_accept=auto_accept)
117

    
118
    if serial:
119
        serial_info = {"serial": serial}
120
        if auto_accept:
121
            serial_info["pending"] = False
122
            serial_info["accept"] = True
123
            serial_info["resolved"] = True
124
        return QuotaHolderSerial.objects.create(**serial_info)
125
    else:
126
        raise Exception("No serial")
127

    
128

    
129
def accept_serial(serial, strict=True):
130
    assert serial.pending or serial.accept
131
    response = resolve_commissions(accept=[serial.serial], strict=strict)
132
    serial.pending = False
133
    serial.accept = True
134
    serial.resolved = True
135
    serial.save()
136
    return response
137

    
138

    
139
def reject_serial(serial, strict=True):
140
    assert serial.pending or not serial.accept
141
    response = resolve_commissions(reject=[serial.serial], strict=strict)
142
    serial.pending = False
143
    serial.accept = False
144
    serial.resolved = True
145
    serial.save()
146
    return response
147

    
148

    
149
def resolve_commissions(accept=None, reject=None, strict=True):
150
    if accept is None:
151
        accept = []
152
    if reject is None:
153
        reject = []
154

    
155
    qh = Quotaholder.get()
156
    with AstakosClientExceptionHandler():
157
        response = qh.resolve_commissions(accept, reject)
158

    
159
    # Update correspodning entries in DB
160
    QuotaHolderSerial.objects.filter(serial__in=accept).update(accept=True,
161
                                                               pending=False,
162
                                                               resolved=True)
163
    QuotaHolderSerial.objects.filter(serial__in=reject).update(accept=False,
164
                                                               pending=False,
165
                                                               resolved=True)
166

    
167
    if strict:
168
        failed = response["failed"]
169
        if failed:
170
            log.error("Unexpected error while resolving commissions: %s",
171
                      failed)
172

    
173
    return response
174

    
175

    
176
def resolve_pending_commissions():
177
    """Resolve quotaholder pending commissions.
178

179
    Get pending commissions from the quotaholder and resolve them
180
    to accepted and rejected, according to the state of the
181
    QuotaHolderSerial DB table. A pending commission in the quotaholder
182
    can exist in the QuotaHolderSerial table and be either accepted or
183
    rejected, or cannot exist in this table, so it is rejected.
184

185
    """
186

    
187
    qh_pending = get_quotaholder_pending()
188
    if not qh_pending:
189
        return ([], [])
190

    
191
    qh_pending.sort()
192
    min_ = qh_pending[0]
193

    
194
    serials = QuotaHolderSerial.objects.filter(serial__gte=min_, pending=False)
195
    accepted = serials.filter(accept=True).values_list('serial', flat=True)
196
    accepted = filter(lambda x: x in qh_pending, accepted)
197

    
198
    rejected = list(set(qh_pending) - set(accepted))
199

    
200
    return (accepted, rejected)
201

    
202

    
203
def get_quotaholder_pending():
204
    qh = Quotaholder.get()
205
    pending_serials = qh.get_pending_commissions()
206
    return pending_serials
207

    
208

    
209
def render_overlimit_exception(e):
210
    resource_name = {"vm": "Virtual Machine",
211
                     "cpu": "CPU",
212
                     "ram": "RAM",
213
                     "network.private": "Private Network",
214
                     "floating_ip": "Floating IP address"}
215
    details = json.loads(e.details)
216
    data = details['overLimit']['data']
217
    usage = data["usage"]
218
    limit = data["limit"]
219
    available = limit - usage
220
    provision = data['provision']
221
    requested = provision['quantity']
222
    resource = provision['resource']
223
    res = resource.replace("cyclades.", "", 1)
224
    try:
225
        resource = resource_name[res]
226
    except KeyError:
227
        resource = res
228

    
229
    msg = "Resource Limit Exceeded for your account."
230
    details = "Limit for resource '%s' exceeded for your account."\
231
              " Available: %s, Requested: %s"\
232
              % (resource, available, requested)
233
    return msg, details
234

    
235

    
236
@transaction.commit_on_success
237
def issue_and_accept_commission(resource, action="BUILD", action_fields=None):
238
    """Issue and accept a commission to Quotaholder.
239

240
    This function implements the Commission workflow, and must be called
241
    exactly after and in the same transaction that created/updated the
242
    resource. The workflow that implements is the following:
243
    0) Resolve previous unresolved commission if exists
244
    1) Issue commission, get a serial and correlate it with the resource
245
    2) Store the serial in DB as a serial to accept
246
    3) COMMIT!
247
    4) Accept commission to QH
248

249
    """
250
    commission_reason = ("client: api, resource: %s, action: %s"
251
                         % (resource, action))
252
    serial = handle_resource_commission(resource=resource, action=action,
253
                                        action_fields=action_fields,
254
                                        commission_name=commission_reason)
255

    
256
    # Mark the serial as one to accept and associate it with the resource
257
    serial.pending = False
258
    serial.accept = True
259
    serial.save()
260
    transaction.commit()
261

    
262
    try:
263
        # Accept the commission to quotaholder
264
        accept_serial(serial)
265
    except:
266
        # Do not crash if we can not accept commission to Quotaholder. Quotas
267
        # have already been reserved and the resource already exists in DB.
268
        # Just log the error
269
        log.exception("Failed to accept commission: %s", serial)
270

    
271
    return serial
272

    
273

    
274
def get_commission_info(resource, action, action_fields=None):
275
    if isinstance(resource, VirtualMachine):
276
        flavor = resource.flavor
277
        resources = {"cyclades.vm": 1,
278
                     "cyclades.total_cpu": flavor.cpu,
279
                     "cyclades.disk": 1073741824 * flavor.disk,
280
                     "cyclades.total_ram": 1048576 * flavor.ram}
281
        online_resources = {"cyclades.cpu": flavor.cpu,
282
                            "cyclades.ram": 1048576 * flavor.ram}
283
        if action == "BUILD":
284
            resources.update(online_resources)
285
            return resources
286
        if action == "START":
287
            if resource.operstate == "STOPPED":
288
                return online_resources
289
            else:
290
                return None
291
        elif action == "STOP":
292
            if resource.operstate in ["STARTED", "BUILD", "ERROR"]:
293
                return reverse_quantities(online_resources)
294
            else:
295
                return None
296
        elif action == "REBOOT":
297
            if resource.operstate == "STOPPED":
298
                return online_resources
299
            else:
300
                return None
301
        elif action == "DESTROY":
302
            if resource.operstate in ["STARTED", "BUILD", "ERROR"]:
303
                resources.update(online_resources)
304
            return reverse_quantities(resources)
305
        elif action == "RESIZE" and action_fields:
306
            beparams = action_fields.get("beparams")
307
            cpu = beparams.get("vcpus", flavor.cpu)
308
            ram = beparams.get("maxmem", flavor.ram)
309
            return {"cyclades.total_cpu": cpu - flavor.cpu,
310
                    "cyclades.total_ram": 1048576 * (ram - flavor.ram)}
311
        else:
312
            #["CONNECT", "DISCONNECT", "SET_FIREWALL_PROFILE"]:
313
            return None
314
    elif isinstance(resource, Network):
315
        resources = {"cyclades.network.private": 1}
316
        if action == "BUILD":
317
            return resources
318
        elif action == "DESTROY":
319
            return reverse_quantities(resources)
320
    elif isinstance(resource, IPAddress):
321
        if resource.floating_ip:
322
            resources = {"cyclades.floating_ip": 1}
323
            if action == "BUILD":
324
                return resources
325
            elif action == "DESTROY":
326
                return reverse_quantities(resources)
327
        else:
328
            return None
329

    
330

    
331
def reverse_quantities(resources):
332
    return dict((r, -s) for r, s in resources.items())
333

    
334

    
335
def handle_resource_commission(resource, action, commission_name,
336
                               force=False, auto_accept=False,
337
                               action_fields=None):
338
    """Handle a issuing of a commission for a resource.
339

340
    Create a new commission for a resource based on the action that
341
    is performed. If the resource has a previous pending commission,
342
    resolved it before issuing the new one.
343

344
    """
345
    # Try to resolve previous serial:
346
    # If action is DESTROY, we must always reject the previous commission,
347
    # since multiple DESTROY actions are allowed in the same resource (e.g. VM)
348
    # The one who succeeds will be finally accepted, and all other will be
349
    # rejected
350
    force = force or (action == "DESTROY")
351
    resolve_commission(resource.serial, force=force)
352

    
353
    serial = issue_commission(resource, action, name=commission_name,
354
                              force=force, auto_accept=auto_accept,
355
                              action_fields=action_fields)
356
    resource.serial = serial
357
    resource.save()
358
    return serial
359

    
360

    
361
class ResolveError(Exception):
362
    pass
363

    
364

    
365
def resolve_commission(serial, force=False):
366
    if serial is None or serial.resolved:
367
        return
368
    if serial.pending and not force:
369
        m = "Could not resolve commission: serial %s is undecided" % serial
370
        raise ResolveError(m)
371
    log.warning("Resolving pending commission: %s", serial)
372
    if not serial.pending and serial.accept:
373
        accept_serial(serial)
374
    else:
375
        reject_serial(serial)