Statistics
| Branch: | Tag: | Revision:

root / snf-cyclades-app / synnefo / quotas / __init__.py @ 449c2d82

History | View | Annotate | Download (12 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
                               FloatingIP)
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,
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,
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
        serial = issue_commission(resource.userid, DEFAULT_SOURCE,
258
                                  qh_resources)
259
    except:
260
        transaction.rollback()
261
        raise
262

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

    
282

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

    
302

    
303
def get_commission_info(resource, action, action_fields=None):
304
    if isinstance(resource, VirtualMachine):
305
        flavor = resource.flavor
306
        resources = {"cyclades.vm": 1,
307
                     "cyclades.cpu": flavor.cpu,
308
                     "cyclades.disk": 1073741824 * flavor.disk,
309
                     "cyclades.ram": 1048576 * flavor.ram}
310
        online_resources = {"cyclades.active_cpu": flavor.cpu,
311
                            "cyclades.active_ram": 1048576 * flavor.ram}
312
        # No commission for build! Commission has already been issued and
313
        # accepted, since the VM has been created in DB.
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 resolve_vm_commission(serial):
352
    log.warning("Resolving pending commission: %s", serial)
353
    if not serial.pending and serial.accept:
354
        accept_serial(serial)
355
    else:
356
        reject_serial(serial)