Statistics
| Branch: | Tag: | Revision:

root / snf-cyclades-app / synnefo / quotas / __init__.py @ 21eb7404

History | View | Annotate | Download (12.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(resource, action, name="", force=False, auto_accept=False,
89
                     action_fields=None):
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
    provisions = get_commission_info(resource=resource, action=action,
98
                                     action_fields=action_fields)
99

    
100
    if provisions is None:
101
        return None
102

    
103
    user = resource.userid
104
    source = DEFAULT_SOURCE
105

    
106
    qh = Quotaholder.get()
107
    try:
108
        if True:  # placeholder
109
            serial = qh.issue_one_commission(user, source,
110
                                             provisions, name=name,
111
                                             force=force,
112
                                             auto_accept=auto_accept)
113
    except QuotaLimit as e:
114
        msg, details = render_overlimit_exception(e)
115
        raise faults.OverLimit(msg, details=details)
116

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

    
127

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

    
137

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

    
147

    
148
def accept_commissions(accepted, strict=True):
149
    return resolve_commissions(accept=accepted, strict=strict)
150

    
151

    
152
def reject_commissions(rejected, strict=True):
153
    return resolve_commissions(reject=rejected, strict=strict)
154

    
155

    
156
@handle_astakosclient_error
157
def resolve_commissions(accept=None, reject=None, strict=True):
158
    if accept is None:
159
        accept = []
160
    if reject is None:
161
        reject = []
162

    
163
    qh = Quotaholder.get()
164
    response = qh.resolve_commissions(accept, reject)
165

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

    
172
    return response
173

    
174

    
175
def fix_pending_commissions():
176
    (accepted, rejected) = resolve_pending_commissions()
177
    resolve_commissions(accept=accepted, reject=rejected)
178

    
179

    
180
def resolve_pending_commissions():
181
    """Resolve quotaholder pending commissions.
182

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

189
    """
190

    
191
    qh_pending = get_quotaholder_pending()
192
    if not qh_pending:
193
        return ([], [])
194

    
195
    qh_pending.sort()
196
    min_ = qh_pending[0]
197

    
198
    serials = QuotaHolderSerial.objects.filter(serial__gte=min_, pending=False)
199
    accepted = serials.filter(accept=True).values_list('serial', flat=True)
200
    accepted = filter(lambda x: x in qh_pending, accepted)
201

    
202
    rejected = list(set(qh_pending) - set(accepted))
203

    
204
    return (accepted, rejected)
205

    
206

    
207
def get_quotaholder_pending():
208
    qh = Quotaholder.get()
209
    pending_serials = qh.get_pending_commissions()
210
    return pending_serials
211

    
212

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

    
233
    msg = "Resource Limit Exceeded for your account."
234
    details = "Limit for resource '%s' exceeded for your account."\
235
              " Available: %s, Requested: %s"\
236
              % (resource, available, requested)
237
    return msg, details
238

    
239

    
240
@transaction.commit_on_success
241
def issue_and_accept_commission(resource, delete=False):
242
    """Issue and accept a commission to Quotaholder.
243

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

253
    """
254
    action = "DESTROY" if delete else "BUILD"
255
    commission_reason = ("client: api, resource: %s, delete: %s"
256
                         % (resource, delete))
257
    serial = handle_resource_commission(resource=resource, action=action,
258
                                        commission_name=commission_reason)
259

    
260
    # Mark the serial as one to accept and associate it with the resource
261
    serial.pending = False
262
    serial.accept = True
263
    serial.save()
264
    transaction.commit()
265

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

    
275
    return serial
276

    
277

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

    
331

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

    
335

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

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

345
    """
346
    # Try to resolve previous serial
347
    resolve_commission(resource.serial, force=force)
348

    
349
    serial = issue_commission(resource, action, name=commission_name,
350
                              force=force, auto_accept=auto_accept,
351
                              action_fields=action_fields)
352
    resource.serial = serial
353
    resource.save()
354
    return serial
355

    
356

    
357
class ResolveError(Exception):
358
    pass
359

    
360

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