Statistics
| Branch: | Tag: | Revision:

root / snf-cyclades-app / synnefo / quotas / __init__.py @ 4fb643b3

History | View | Annotate | Download (12.5 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(ASTAKOS_TOKEN,
67
                                        ASTAKOS_AUTH_URL,
68
                                        use_pool=True,
69
                                        retry=3,
70
                                        logger=log)
71
        return cls._object
72

    
73

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

    
85

    
86
@handle_astakosclient_error
87
def issue_commission(resource, action, name="", force=False, auto_accept=False,
88
                     action_fields=None):
89
    """Issue a new commission to the quotaholder.
90

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

94
    """
95

    
96
    provisions = get_commission_info(resource=resource, action=action,
97
                                     action_fields=action_fields)
98

    
99
    if provisions is None:
100
        return None
101

    
102
    user = resource.userid
103
    source = DEFAULT_SOURCE
104

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

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

    
126

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

    
136

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

    
146

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

    
150

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

    
154

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

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

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

    
171
    return response
172

    
173

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

    
178

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

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

188
    """
189

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

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

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

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

    
203
    return (accepted, rejected)
204

    
205

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

    
211

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

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

    
238

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

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

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

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

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

    
274
    return serial
275

    
276

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

    
330

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

    
334

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

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

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

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

    
355

    
356
class ResolveError(Exception):
357
    pass
358

    
359

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