Statistics
| Branch: | Tag: | Revision:

root / snf-cyclades-app / synnefo / quotas / __init__.py @ 64bca363

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["pending"] = False
110
            serial_info["accept"] = True
111
            serial_info["resolved"] = True
112
        return QuotaHolderSerial.objects.create(**serial_info)
113
    else:
114
        raise Exception("No serial")
115

    
116

    
117
def accept_serial(serial, strict=True):
118
    assert serial.pending or serial.accept
119
    response = resolve_commissions(accept=[serial.serial], strict=strict)
120
    serial.pending = False
121
    serial.accept = True
122
    serial.resolved = True
123
    serial.save()
124
    return response
125

    
126

    
127
def reject_serial(serial, strict=True):
128
    assert serial.pending or not serial.accept
129
    response = resolve_commissions(reject=[serial.serial], strict=strict)
130
    serial.pending = False
131
    serial.accept = False
132
    serial.resolved = True
133
    serial.save()
134
    return response
135

    
136

    
137
def accept_commissions(accepted, strict=True):
138
    return resolve_commissions(accept=accepted, strict=strict)
139

    
140

    
141
def reject_commissions(rejected, strict=True):
142
    return resolve_commissions(reject=rejected, strict=strict)
143

    
144

    
145
@handle_astakosclient_error
146
def resolve_commissions(accept=None, reject=None, strict=True):
147
    if accept is None:
148
        accept = []
149
    if reject is None:
150
        reject = []
151

    
152
    qh = Quotaholder.get()
153
    response = qh.resolve_commissions(accept, reject)
154

    
155
    if strict:
156
        failed = response["failed"]
157
        if failed:
158
            log.error("Unexpected error while resolving commissions: %s",
159
                      failed)
160

    
161
    return response
162

    
163

    
164
def fix_pending_commissions():
165
    (accepted, rejected) = resolve_pending_commissions()
166
    resolve_commissions(accept=accepted, reject=rejected)
167

    
168

    
169
def resolve_pending_commissions():
170
    """Resolve quotaholder pending commissions.
171

172
    Get pending commissions from the quotaholder and resolve them
173
    to accepted and rejected, according to the state of the
174
    QuotaHolderSerial DB table. A pending commission in the quotaholder
175
    can exist in the QuotaHolderSerial table and be either accepted or
176
    rejected, or cannot exist in this table, so it is rejected.
177

178
    """
179

    
180
    qh_pending = get_quotaholder_pending()
181
    if not qh_pending:
182
        return ([], [])
183

    
184
    qh_pending.sort()
185
    min_ = qh_pending[0]
186

    
187
    serials = QuotaHolderSerial.objects.filter(serial__gte=min_, pending=False)
188
    accepted = serials.filter(accept=True).values_list('serial', flat=True)
189
    accepted = filter(lambda x: x in qh_pending, accepted)
190

    
191
    rejected = list(set(qh_pending) - set(accepted))
192

    
193
    return (accepted, rejected)
194

    
195

    
196
def get_quotaholder_pending():
197
    qh = Quotaholder.get()
198
    pending_serials = qh.get_pending_commissions()
199
    return pending_serials
200

    
201

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

    
222
    msg = "Resource Limit Exceeded for your account."
223
    details = "Limit for resource '%s' exceeded for your account."\
224
              " Available: %s, Requested: %s"\
225
              % (resource, available, requested)
226
    return msg, details
227

    
228

    
229
@transaction.commit_manually
230
def issue_and_accept_commission(resource, delete=False):
231
    """Issue and accept a commission to Quotaholder.
232

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

245
    """
246
    resolve_commission(resource.serial)
247

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

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

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

    
283

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

    
304

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

    
346

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

    
350

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

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

360
    """
361
    # Try to resolve previous serial
362
    resolve_commission(resource.serial, force=force)
363

    
364
    # Check if action is quotable and issue the corresponding commission
365
    serial = None
366
    if commission_info is None:
367
        commission_info = get_commission_info(
368
            resource, action=action, action_fields=action_fields)
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=force,
378
                                  auto_accept=auto_accept)
379
    resource.serial = serial
380

    
381

    
382
class ResolveError(Exception):
383
    pass
384

    
385

    
386
def resolve_commission(serial, force=False):
387
    if serial is None or serial.resolved:
388
        return
389
    if serial.pending and not force:
390
        m = "Could not resolve commission: serial %s is undecided" % serial
391
        raise ResolveError(m)
392
    log.warning("Resolving pending commission: %s", serial)
393
    if not serial.pending and serial.accept:
394
        accept_serial(serial)
395
    else:
396
        reject_serial(serial)