Statistics
| Branch: | Tag: | Revision:

root / snf-cyclades-app / synnefo / quotas / __init__.py @ 02afb932

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_AUTH_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

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

    
59

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

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

    
74

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

    
86

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

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

95
    """
96

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

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

    
115

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

    
123

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

    
131

    
132
def accept_commissions(accepted, strict=True):
133
    return resolve_commissions(accept=accepted, strict=strict)
134

    
135

    
136
def reject_commissions(rejected, strict=True):
137
    return resolve_commissions(reject=rejected, strict=strict)
138

    
139

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

    
147
    qh = Quotaholder.get()
148
    response = qh.resolve_commissions(accept, reject)
149

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

    
156
    return response
157

    
158

    
159
def fix_pending_commissions():
160
    (accepted, rejected) = resolve_pending_commissions()
161
    resolve_commissions(accept=accepted, reject=rejected)
162

    
163

    
164
def resolve_pending_commissions():
165
    """Resolve quotaholder pending commissions.
166

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

173
    """
174

    
175
    qh_pending = get_quotaholder_pending()
176
    if not qh_pending:
177
        return ([], [])
178

    
179
    qh_pending.sort()
180
    min_ = qh_pending[0]
181

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

    
186
    rejected = list(set(qh_pending) - set(accepted))
187

    
188
    return (accepted, rejected)
189

    
190

    
191
def get_quotaholder_pending():
192
    qh = Quotaholder.get()
193
    pending_serials = qh.get_pending_commissions()
194
    return pending_serials
195

    
196

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

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

    
223

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

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

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

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

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

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

    
287

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

    
308

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

    
350

    
351
def reverse_quantities(resources):
352
    return dict((r, -s) for r, s in resources.items())
353

    
354

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

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

364
    """
365
    # Try to resolve previous serial
366
    resolve_commission(resource.serial)
367

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

    
384

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