Statistics
| Branch: | Tag: | Revision:

root / snf-cyclades-app / synnefo / quotas / __init__.py @ 326c3ec8

History | View | Annotate | Download (13.4 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_BASE_URL)
39
from astakosclient import AstakosClient
40
from astakosclient.errors import AstakosClientException, QuotaLimit
41
from functools import wraps
42

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

    
46
DEFAULT_SOURCE = 'system'
47
RESOURCES = [
48
    "cyclades.vm",
49
    "cyclades.cpu",
50
    "cyclades.active_cpu",
51
    "cyclades.disk",
52
    "cyclades.ram",
53
    "cyclades.active_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(
66
                ASTAKOS_BASE_URL,
67
                use_pool=True,
68
                retry=3,
69
                logger=log)
70
        return cls._object
71

    
72

    
73
def handle_astakosclient_error(func):
74
    """Decorator for converting astakosclient errors to 500."""
75
    @wraps(func)
76
    def wrapper(*args, **kwargs):
77
        try:
78
            return func(*args, **kwargs)
79
        except AstakosClientException:
80
            log.exception("Unexpected error")
81
            raise faults.InternalServerError("Unexpected error")
82
    return wrapper
83

    
84

    
85
@handle_astakosclient_error
86
def issue_commission(user, source, provisions, name="",
87
                     force=False, auto_accept=False):
88
    """Issue a new commission to the quotaholder.
89

90
    Issue a new commission to the quotaholder, and create the
91
    corresponing QuotaHolderSerial object in DB.
92

93
    """
94

    
95
    qh = Quotaholder.get()
96
    try:
97
        serial = qh.issue_one_commission(ASTAKOS_TOKEN,
98
                                         user, source, provisions, name=name,
99
                                         force=force, auto_accept=auto_accept)
100
    except QuotaLimit as e:
101
        msg, details = render_overlimit_exception(e)
102
        raise faults.OverLimit(msg, details=details)
103

    
104
    if serial:
105
        serial_info = {"serial": serial}
106
        if auto_accept:
107
            serial_info["accept"] = True
108
            serial_info["resolved"] = True
109
        return QuotaHolderSerial.objects.create(**serial_info)
110
    else:
111
        raise Exception("No serial")
112

    
113

    
114
def accept_serial(serial, strict=True):
115
    response = resolve_commissions(accept=[serial.serial], strict=strict)
116
    serial.accept = True
117
    serial.resolved = True
118
    serial.save()
119
    return response
120

    
121

    
122
def reject_serial(serial, strict=True):
123
    response = resolve_commissions(reject=[serial.serial], strict=strict)
124
    serial.reject = True
125
    serial.resolved = True
126
    serial.save()
127
    return response
128

    
129

    
130
def accept_commissions(accepted, strict=True):
131
    return resolve_commissions(accept=accepted, strict=strict)
132

    
133

    
134
def reject_commissions(rejected, strict=True):
135
    return resolve_commissions(reject=rejected, strict=strict)
136

    
137

    
138
@handle_astakosclient_error
139
def resolve_commissions(accept=None, reject=None, strict=True):
140
    if accept is None:
141
        accept = []
142
    if reject is None:
143
        reject = []
144

    
145
    qh = Quotaholder.get()
146
    response = qh.resolve_commissions(ASTAKOS_TOKEN, accept, reject)
147

    
148
    if strict:
149
        failed = response["failed"]
150
        if failed:
151
            log.error("Unexpected error while resolving commissions: %s",
152
                      failed)
153

    
154
    return response
155

    
156

    
157
def fix_pending_commissions():
158
    (accepted, rejected) = resolve_pending_commissions()
159
    resolve_commissions(accept=accepted, reject=rejected)
160

    
161

    
162
def resolve_pending_commissions():
163
    """Resolve quotaholder pending commissions.
164

165
    Get pending commissions from the quotaholder and resolve them
166
    to accepted and rejected, according to the state of the
167
    QuotaHolderSerial DB table. A pending commission in the quotaholder
168
    can exist in the QuotaHolderSerial table and be either accepted or
169
    rejected, or can not exist in this table, so it is rejected.
170

171
    """
172

    
173
    qh_pending = get_quotaholder_pending()
174
    if not qh_pending:
175
        return ([], [])
176

    
177
    qh_pending.sort()
178
    min_ = qh_pending[0]
179

    
180
    serials = QuotaHolderSerial.objects.filter(serial__gte=min_, pending=False)
181
    accepted = serials.filter(accept=True).values_list('serial', flat=True)
182
    accepted = filter(lambda x: x in qh_pending, accepted)
183

    
184
    rejected = list(set(qh_pending) - set(accepted))
185

    
186
    return (accepted, rejected)
187

    
188

    
189
def get_quotaholder_pending():
190
    qh = Quotaholder.get()
191
    pending_serials = qh.get_pending_commissions(ASTAKOS_TOKEN)
192
    return pending_serials
193

    
194

    
195
def render_overlimit_exception(e):
196
    resource_name = {"vm": "Virtual Machine",
197
                     "cpu": "CPU",
198
                     "ram": "RAM",
199
                     "network.private": "Private Network",
200
                     "floating_ip": "Floating IP address"}
201
    details = json.loads(e.details)
202
    data = details['overLimit']['data']
203
    usage = data["usage"]
204
    limit = data["limit"]
205
    available = limit - usage
206
    provision = data['provision']
207
    requested = provision['quantity']
208
    resource = provision['resource']
209
    res = resource.replace("cyclades.", "", 1)
210
    try:
211
        resource = resource_name[res]
212
    except KeyError:
213
        resource = res
214

    
215
    msg = "Resource Limit Exceeded for your account."
216
    details = "Limit for resource '%s' exceeded for your account."\
217
              " Available: %s, Requested: %s"\
218
              % (resource, available, requested)
219
    return msg, details
220

    
221

    
222
@transaction.commit_manually
223
def issue_and_accept_commission(resource, delete=False):
224
    """Issue and accept a commission to Quotaholder.
225

226
    This function implements the Commission workflow, and must be called
227
    exactly after and in the same transaction that created/updated the
228
    resource. The workflow that implements is the following:
229
    0) Resolve previous unresolved commission if exists
230
    1) Issue commission and get a serial
231
    2) Store the serial in DB and mark is as one to accept
232
    3) Correlate the serial with the resource
233
    4) COMMIT!
234
    5) Accept commission to QH (reject if failed until 5)
235
    6) Mark serial as resolved
236
    7) COMMIT!
237

238
    """
239
    previous_serial = resource.serial
240
    if previous_serial is not None and not previous_serial.resolved:
241
        if previous_serial.pending:
242
            msg = "Issuing commission for resource '%s' while previous serial"\
243
                  " '%s' is still pending." % (resource, previous_serial)
244
            raise Exception(msg)
245
        elif previous_serial.accept:
246
            accept_serial(previous_serial, strict=False)
247
        else:
248
            reject_serial(previous_serial, strict=False)
249

    
250
    try:
251
        # Convert resources in the format expected by Quotaholder
252
        qh_resources = prepare_qh_resources(resource)
253
        if delete:
254
            qh_resources = reverse_quantities(qh_resources)
255

    
256
        # Issue commission and get the assigned serial
257
        commission_reason = ("client: api, resource: %s, delete: %s"
258
                             % (resource, delete))
259
        serial = issue_commission(user=resource.userid, source=DEFAULT_SOURCE,
260
                                  provisions=qh_resources,
261
                                  name=commission_reason)
262
    except:
263
        transaction.rollback()
264
        raise
265

    
266
    try:
267
        # Mark the serial as one to accept and associate it with the resource
268
        serial.pending = False
269
        serial.accept = True
270
        serial.save()
271
        resource.serial = serial
272
        resource.save()
273
        transaction.commit()
274
        # Accept the commission to quotaholder
275
        accept_serial(serial)
276
        transaction.commit()
277
        return serial
278
    except:
279
        log.exception("Unexpected ERROR")
280
        transaction.rollback()
281
        reject_serial(serial)
282
        transaction.commit()
283
        raise
284

    
285

    
286
def prepare_qh_resources(resource):
287
    if isinstance(resource, VirtualMachine):
288
        flavor = resource.flavor
289
        return {'cyclades.vm': 1,
290
                'cyclades.cpu': flavor.cpu,
291
                'cyclades.active_cpu': flavor.cpu,
292
                'cyclades.disk': 1073741824 * flavor.disk,  # flavor.disk in GB
293
                # 'public_ip': 1,
294
                #'disk_template': flavor.disk_template,
295
                # flavor.ram is in MB
296
                'cyclades.ram': 1048576 * flavor.ram,
297
                'cyclades.active_ram': 1048576 * flavor.ram}
298
    elif isinstance(resource, Network):
299
        return {"cyclades.network.private": 1}
300
    elif isinstance(resource, IPAddress):
301
        return {"cyclades.floating_ip": 1}
302
    else:
303
        raise ValueError("Unknown Resource '%s'" % resource)
304

    
305

    
306
def get_commission_info(resource, action, action_fields=None):
307
    if isinstance(resource, VirtualMachine):
308
        flavor = resource.flavor
309
        resources = {"cyclades.vm": 1,
310
                     "cyclades.cpu": flavor.cpu,
311
                     "cyclades.disk": 1073741824 * flavor.disk,
312
                     "cyclades.ram": 1048576 * flavor.ram}
313
        online_resources = {"cyclades.active_cpu": flavor.cpu,
314
                            "cyclades.active_ram": 1048576 * flavor.ram}
315
        if action == "BUILD":
316
            resources.update(online_resources)
317
            return resources
318
        if action == "START":
319
            if resource.operstate == "STOPPED":
320
                return online_resources
321
            else:
322
                return None
323
        elif action == "STOP":
324
            if resource.operstate in ["STARTED", "BUILD", "ERROR"]:
325
                return reverse_quantities(online_resources)
326
            else:
327
                return None
328
        elif action == "REBOOT":
329
            if resource.operstate == "STOPPED":
330
                return online_resources
331
            else:
332
                return None
333
        elif action == "DESTROY":
334
            if resource.operstate in ["STARTED", "BUILD", "ERROR"]:
335
                resources.update(online_resources)
336
            return reverse_quantities(resources)
337
        elif action == "RESIZE" and action_fields:
338
            beparams = action_fields.get("beparams")
339
            cpu = beparams.get("vcpus", flavor.cpu)
340
            ram = beparams.get("maxmem", flavor.ram)
341
            return {"cyclades.cpu": cpu - flavor.cpu,
342
                    "cyclades.ram": 1048576 * (ram - flavor.ram)}
343
        else:
344
            #["CONNECT", "DISCONNECT", "SET_FIREWALL_PROFILE"]:
345
            return None
346

    
347

    
348
def reverse_quantities(resources):
349
    return dict((r, -s) for r, s in resources.items())
350

    
351

    
352
def handle_resource_commission(resource, action, commission_name,
353
                               commission_info=None, force=False,
354
                               auto_accept=False):
355
    """Handle a issuing of a commission for a resource.
356

357
    Create a new commission for a resource based on the action that
358
    is performed. If the resource has a previous pending commission,
359
    resolved it before issuing the new one.
360

361
    """
362
    # Try to resolve previous serial
363
    resolve_commission(resource.serial)
364

    
365
    # Check if action is quotable and issue the corresponding commission
366
    serial = None
367
    if commission_info is None:
368
        commission_info = get_commission_info(resource, action=action)
369
    if commission_info is not None:
370
        # Issue new commission, associate it with the resource
371
        if commission_name is None:
372
            commission_name = "client: api, resource %s" % resource
373
        serial = issue_commission(user=resource.userid,
374
                                  source=DEFAULT_SOURCE,
375
                                  provisions=commission_info,
376
                                  name=commission_name,
377
                                  force=False,
378
                                  auto_accept=False)
379
    resource.serial = serial
380

    
381

    
382
def resolve_commission(serial):
383
    if serial is None or serial.resolved:
384
        return
385
    log.warning("Resolving pending commission: %s", serial)
386
    if not serial.pending and serial.accept:
387
        accept_serial(serial)
388
    else:
389
        reject_serial(serial)