Revision 629acc65
b/snf-cyclades-app/synnefo/api/management/commands/cyclades-astakos-migrate-013.py | ||
---|---|---|
1 |
# Copyright 2012 GRNET S.A. All rights reserved. |
|
1 |
# Copyright 2012, 2013 GRNET S.A. All rights reserved.
|
|
2 | 2 |
# |
3 | 3 |
# Redistribution and use in source and binary forms, with or |
4 | 4 |
# without modification, are permitted provided that the following |
... | ... | |
41 | 41 |
from django.db import transaction |
42 | 42 |
from django.conf import settings |
43 | 43 |
|
44 |
from synnefo.quotas import get_quota_holder |
|
45 | 44 |
from synnefo.api.util import get_existing_users |
46 | 45 |
from synnefo.lib.utils import case_unique |
47 | 46 |
from synnefo.db.models import Network, VirtualMachine |
b/snf-cyclades-app/synnefo/quotas/__init__.py | ||
---|---|---|
1 |
# Copyright 2012 GRNET S.A. All rights reserved. |
|
1 |
# Copyright 2012, 2013 GRNET S.A. All rights reserved.
|
|
2 | 2 |
# |
3 | 3 |
# Redistribution and use in source and binary forms, with or without |
4 | 4 |
# modification, are permitted provided that the following conditions |
... | ... | |
31 | 31 |
from contextlib import contextmanager |
32 | 32 |
|
33 | 33 |
from snf_django.lib.api import faults |
34 |
from synnefo.db.models import QuotaHolderSerial, VirtualMachine, Network
|
|
34 |
from synnefo.db.models import QuotaHolderSerial |
|
35 | 35 |
from synnefo.settings import CYCLADES_USE_QUOTAHOLDER |
36 | 36 |
|
37 |
if CYCLADES_USE_QUOTAHOLDER: |
|
38 |
from synnefo.settings import (CYCLADES_QUOTAHOLDER_URL, |
|
39 |
CYCLADES_QUOTAHOLDER_TOKEN, |
|
40 |
CYCLADES_QUOTAHOLDER_POOLSIZE) |
|
41 |
from synnefo.lib.quotaholder import QuotaholderClient |
|
42 |
else: |
|
43 |
from synnefo.settings import (VMS_USER_QUOTA, MAX_VMS_PER_USER, |
|
44 |
NETWORKS_USER_QUOTA, MAX_NETWORKS_PER_USER) |
|
45 |
|
|
46 |
from synnefo.lib.quotaholder.api import (NoCapacityError, NoQuantityError, |
|
47 |
NoEntityError, CallError) |
|
37 |
from synnefo.settings import (CYCLADES_ASTAKOS_SERVICE_TOKEN as ASTAKOS_TOKEN, |
|
38 |
ASTAKOS_URL) |
|
39 |
from astakosclient import AstakosClient |
|
40 |
from astakosclient.errors import AstakosClientException, QuotaLimit |
|
48 | 41 |
|
49 | 42 |
import logging |
50 | 43 |
log = logging.getLogger(__name__) |
51 | 44 |
|
45 |
DEFAULT_SOURCE = 'system' |
|
52 | 46 |
|
53 |
class DummySerial(QuotaHolderSerial): |
|
54 |
accepted = True |
|
55 |
rejected = True |
|
56 |
pending = True |
|
57 |
id = None |
|
58 |
|
|
59 |
def save(*args, **kwargs): |
|
60 |
pass |
|
61 |
|
|
62 |
|
|
63 |
class DummyQuotaholderClient(object): |
|
64 |
def issue_commission(self, **commission_info): |
|
65 |
provisions = commission_info["provisions"] |
|
66 |
userid = commission_info["target"] |
|
67 |
for provision in provisions: |
|
68 |
entity, resource, size = provision |
|
69 |
if resource == "cyclades.vm" and size > 0: |
|
70 |
user_vms = VirtualMachine.objects.filter(userid=userid, |
|
71 |
deleted=False).count() |
|
72 |
user_vm_limit = VMS_USER_QUOTA.get(userid, MAX_VMS_PER_USER) |
|
73 |
log.debug("Users VMs %s User Limits %s", user_vms, |
|
74 |
user_vm_limit) |
|
75 |
if user_vms + size > user_vm_limit: |
|
76 |
raise NoQuantityError(source='cyclades', |
|
77 |
target=userid, |
|
78 |
resource=resource, |
|
79 |
requested=size, |
|
80 |
current=user_vms, |
|
81 |
limit=user_vm_limit) |
|
82 |
if resource == "cyclades.network.private" and size > 0: |
|
83 |
user_networks = Network.objects.filter(userid=userid, |
|
84 |
deleted=False).count() |
|
85 |
user_network_limit =\ |
|
86 |
NETWORKS_USER_QUOTA.get(userid, MAX_NETWORKS_PER_USER) |
|
87 |
if user_networks + size > user_network_limit: |
|
88 |
raise NoQuantityError(source='cyclades', |
|
89 |
target=userid, |
|
90 |
resource=resource, |
|
91 |
requested=size, |
|
92 |
current=user_networks, |
|
93 |
limit=user_network_limit) |
|
94 |
|
|
95 |
return None |
|
96 |
|
|
97 |
def accept_commission(self, *args, **kwargs): |
|
98 |
pass |
|
99 |
|
|
100 |
def reject_commission(self, *args, **kwargs): |
|
101 |
pass |
|
102 |
|
|
103 |
def get_pending_commissions(self, *args, **kwargs): |
|
104 |
return [] |
|
105 |
|
|
106 |
|
|
107 |
@contextmanager |
|
108 |
def get_quota_holder(): |
|
109 |
"""Context manager for using a QuotaHolder.""" |
|
110 |
if CYCLADES_USE_QUOTAHOLDER: |
|
111 |
quotaholder = QuotaholderClient(CYCLADES_QUOTAHOLDER_URL, |
|
112 |
token=CYCLADES_QUOTAHOLDER_TOKEN, |
|
113 |
poolsize=CYCLADES_QUOTAHOLDER_POOLSIZE) |
|
114 |
else: |
|
115 |
quotaholder = DummyQuotaholderClient() |
|
116 | 47 |
|
117 |
try: |
|
118 |
yield quotaholder |
|
119 |
finally: |
|
120 |
pass |
|
48 |
class Quotaholder(object): |
|
49 |
_object = None |
|
50 |
|
|
51 |
@classmethod |
|
52 |
def get(cls): |
|
53 |
if cls._object is None: |
|
54 |
cls._object = AstakosClient( |
|
55 |
ASTAKOS_URL, |
|
56 |
use_pool=True, |
|
57 |
logger=log) |
|
58 |
return cls._object |
|
121 | 59 |
|
122 | 60 |
|
123 | 61 |
def uses_commission(func): |
... | ... | |
171 | 109 |
s.accepted = True |
172 | 110 |
s.save() |
173 | 111 |
|
174 |
with get_quota_holder() as qh: |
|
175 |
qh.accept_commission(context={}, |
|
176 |
clientkey='cyclades', |
|
177 |
serials=[s.serial for s in serials]) |
|
112 |
accept_serials = [s.serial for s in serials] |
|
113 |
qh_resolve_commissions(accept=accept_serials) |
|
178 | 114 |
|
179 | 115 |
|
180 | 116 |
def reject_commission(serials, update_db=True): |
... | ... | |
189 | 125 |
s.rejected = True |
190 | 126 |
s.save() |
191 | 127 |
|
192 |
with get_quota_holder() as qh: |
|
193 |
qh.reject_commission(context={}, |
|
194 |
clientkey='cyclades', |
|
195 |
serials=[s.serial for s in serials]) |
|
128 |
reject_serials = [s.serial for s in serials] |
|
129 |
qh_resolve_commissions(reject=reject_serials) |
|
196 | 130 |
|
197 | 131 |
|
198 |
def issue_commission(**commission_info): |
|
132 |
def issue_commission(user, source, provisions, |
|
133 |
force=False, auto_accept=False): |
|
199 | 134 |
"""Issue a new commission to the quotaholder. |
200 | 135 |
|
201 | 136 |
Issue a new commission to the quotaholder, and create the |
... | ... | |
203 | 138 |
|
204 | 139 |
""" |
205 | 140 |
|
206 |
with get_quota_holder() as qh: |
|
207 |
try: |
|
208 |
serial = qh.issue_commission(**commission_info) |
|
209 |
except (NoCapacityError, NoQuantityError) as e: |
|
210 |
msg, details = render_quotaholder_exception(e) |
|
211 |
raise faults.OverLimit(msg, details=details) |
|
212 |
except CallError as e: |
|
213 |
log.exception("Unexpected error") |
|
214 |
raise |
|
141 |
qh = Quotaholder.get() |
|
142 |
try: |
|
143 |
serial = qh.issue_one_commission(ASTAKOS_TOKEN, |
|
144 |
user, source, provisions, |
|
145 |
force, auto_accept) |
|
146 |
except QuotaLimit as e: |
|
147 |
msg, details = render_overlimit_exception(e) |
|
148 |
raise faults.OverLimit(msg, details=details) |
|
149 |
except AstakosClientException as e: |
|
150 |
log.exception("Unexpected error") |
|
151 |
raise |
|
215 | 152 |
|
216 | 153 |
if serial: |
217 | 154 |
return QuotaHolderSerial.objects.create(serial=serial) |
218 |
elif not CYCLADES_USE_QUOTAHOLDER: |
|
219 |
return DummySerial() |
|
220 | 155 |
else: |
221 | 156 |
raise Exception("No serial") |
222 | 157 |
|
... | ... | |
228 | 163 |
|
229 | 164 |
|
230 | 165 |
def issue_vm_commission(user, flavor, delete=False): |
231 |
resources = get_server_resources(flavor) |
|
232 |
commission_info = create_commission(user, resources, delete) |
|
233 |
|
|
234 |
return issue_commission(**commission_info) |
|
166 |
resources = prepare(get_server_resources(flavor), delete) |
|
167 |
return issue_commission(user, DEFAULT_SOURCE, resources) |
|
235 | 168 |
|
236 | 169 |
|
237 | 170 |
def get_server_resources(flavor): |
... | ... | |
244 | 177 |
|
245 | 178 |
|
246 | 179 |
def issue_network_commission(user, delete=False): |
247 |
resources = get_network_resources() |
|
248 |
commission_info = create_commission(user, resources, delete) |
|
249 |
|
|
250 |
return issue_commission(**commission_info) |
|
180 |
resources = prepare(get_network_resources(), delete) |
|
181 |
return issue_commission(user, DEFAULT_SOURCE, resources) |
|
251 | 182 |
|
252 | 183 |
|
253 | 184 |
def get_network_resources(): |
254 | 185 |
return {"network.private": 1} |
255 | 186 |
|
256 | 187 |
|
257 |
def invert_resources(resources_dict): |
|
258 |
return dict((r, -s) for r, s in resources_dict.items()) |
|
259 |
|
|
260 |
|
|
261 |
def create_commission(user, resources, delete=False): |
|
188 |
def prepare(resources_dict, delete): |
|
262 | 189 |
if delete: |
263 |
resources = invert_resources(resources) |
|
264 |
provisions = [('cyclades', 'cyclades.' + r, s) |
|
265 |
for r, s in resources.items()] |
|
266 |
return {"context": {}, |
|
267 |
"target": user, |
|
268 |
"key": "1", |
|
269 |
"clientkey": "cyclades", |
|
270 |
#"owner": "", |
|
271 |
#"ownerkey": "1", |
|
272 |
"name": "", |
|
273 |
"provisions": provisions} |
|
190 |
return dict((r, -s) for r, s in resources_dict.items()) |
|
191 |
return resources_dict |
|
192 |
|
|
274 | 193 |
|
275 | 194 |
## |
276 | 195 |
## Reconcile pending commissions |
... | ... | |
278 | 197 |
|
279 | 198 |
|
280 | 199 |
def accept_commissions(accepted): |
281 |
with get_quota_holder() as qh: |
|
282 |
qh.accept_commission(context={}, |
|
283 |
clientkey='cyclades', |
|
284 |
serials=accepted) |
|
200 |
qh_resolve_commissions(accept=accepted) |
|
285 | 201 |
|
286 | 202 |
|
287 | 203 |
def reject_commissions(rejected): |
288 |
with get_quota_holder() as qh: |
|
289 |
qh.reject_commission(context={}, |
|
290 |
clientkey='cyclades', |
|
291 |
serials=rejected) |
|
204 |
qh_resolve_commissions(reject=rejected) |
|
292 | 205 |
|
293 | 206 |
|
294 | 207 |
def fix_pending_commissions(): |
295 | 208 |
(accepted, rejected) = resolve_pending_commissions() |
209 |
qh_resolve_commissions(accepted, rejected) |
|
210 |
|
|
211 |
|
|
212 |
def qh_resolve_commissions(accept=None, reject=None): |
|
213 |
if accept is None: |
|
214 |
accept = [] |
|
215 |
if reject is None: |
|
216 |
reject = [] |
|
296 | 217 |
|
297 |
with get_quota_holder() as qh: |
|
298 |
if accepted: |
|
299 |
qh.accept_commission(context={}, |
|
300 |
clientkey='cyclades', |
|
301 |
serials=accepted) |
|
302 |
if rejected: |
|
303 |
qh.reject_commission(context={}, |
|
304 |
clientkey='cyclades', |
|
305 |
serials=rejected) |
|
218 |
qh = Quotaholder.get() |
|
219 |
qh.resolve_commissions(ASTAKOS_TOKEN, accept, reject) |
|
306 | 220 |
|
307 | 221 |
|
308 | 222 |
def resolve_pending_commissions(): |
... | ... | |
333 | 247 |
|
334 | 248 |
|
335 | 249 |
def get_quotaholder_pending(): |
336 |
with get_quota_holder() as qh: |
|
337 |
pending_serials = qh.get_pending_commissions(context={}, |
|
338 |
clientkey='cyclades') |
|
250 |
qh = Quotaholder.get() |
|
251 |
pending_serials = qh.get_pending_commissions(ASTAKOS_TOKEN) |
|
339 | 252 |
return pending_serials |
340 | 253 |
|
341 | 254 |
|
342 |
def render_quotaholder_exception(e):
|
|
255 |
def render_overlimit_exception(e):
|
|
343 | 256 |
resource_name = {"vm": "Virtual Machine", |
344 | 257 |
"cpu": "CPU", |
345 | 258 |
"ram": "RAM", |
346 | 259 |
"network.private": "Private Network"} |
347 |
res = e.resource.replace("cyclades.", "", 1) |
|
260 |
details = e.details |
|
261 |
data = details['overLimit']['data'] |
|
262 |
available = data['available'] |
|
263 |
provision = data['provision'] |
|
264 |
requested = provision['quantity'] |
|
265 |
resource = provision['resource'] |
|
266 |
res = resource.replace("cyclades.", "", 1) |
|
348 | 267 |
try: |
349 | 268 |
resource = resource_name[res] |
350 | 269 |
except KeyError: |
351 | 270 |
resource = res |
352 | 271 |
|
353 |
requested = e.requested |
|
354 |
current = e.current |
|
355 |
limit = e.limit |
|
356 | 272 |
msg = "Resource Limit Exceeded for your account." |
357 | 273 |
details = "Limit for resource '%s' exceeded for your account."\ |
358 |
" Current value: %s, Limit: %s, Requested: %s"\
|
|
359 |
% (resource, current, limit, requested)
|
|
274 |
" Available: %s, Requested: %s"\
|
|
275 |
% (resource, available, requested)
|
|
360 | 276 |
return msg, details |
b/snf-cyclades-app/synnefo/quotas/management/commands/cyclades-reset-usage.py | ||
---|---|---|
1 |
# Copyright 2012 GRNET S.A. All rights reserved. |
|
1 |
# Copyright 2012, 2013 GRNET S.A. All rights reserved.
|
|
2 | 2 |
# |
3 | 3 |
# Redistribution and use in source and binary forms, with or |
4 | 4 |
# without modification, are permitted provided that the following |
... | ... | |
34 | 34 |
from django.core.management.base import BaseCommand |
35 | 35 |
from optparse import make_option |
36 | 36 |
|
37 |
from synnefo.quotas import get_quota_holder
|
|
37 |
from synnefo.quotas import Quotaholder
|
|
38 | 38 |
from synnefo.quotas.util import get_db_holdings |
39 | 39 |
|
40 | 40 |
|
... | ... | |
58 | 58 |
db_holdings = get_db_holdings(users) |
59 | 59 |
|
60 | 60 |
# Create commissions |
61 |
with get_quota_holder() as qh: |
|
62 |
for user, resources in db_holdings.items(): |
|
63 |
if not user: |
|
64 |
continue |
|
65 |
reset_holding = [] |
|
66 |
for res, val in resources.items(): |
|
67 |
reset_holding.append((user, "cyclades." + res, "1", val, 0, |
|
68 |
0, 0)) |
|
69 |
if not options['dry_run']: |
|
70 |
try: |
|
71 |
qh.reset_holding(context={}, |
|
72 |
reset_holding=reset_holding) |
|
73 |
except Exception as e: |
|
74 |
self.stderr.write("Can not set up holding:%s" % e) |
|
75 |
else: |
|
76 |
self.stdout.write("Reseting holding: %s\n" % reset_holding) |
|
61 |
qh = Quotaholder.get() |
|
62 |
for user, resources in db_holdings.items(): |
|
63 |
if not user: |
|
64 |
continue |
|
65 |
reset_holding = [] |
|
66 |
for res, val in resources.items(): |
|
67 |
reset_holding.append((user, "cyclades." + res, "1", val, 0, |
|
68 |
0, 0)) |
|
69 |
if not options['dry_run']: |
|
70 |
try: |
|
71 |
qh.reset_holding(context={}, |
|
72 |
reset_holding=reset_holding) |
|
73 |
except Exception as e: |
|
74 |
self.stderr.write("Can not set up holding:%s" % e) |
|
75 |
else: |
|
76 |
self.stdout.write("Reseting holding: %s\n" % reset_holding) |
b/snf-cyclades-app/synnefo/quotas/management/commands/cyclades-usage-verify.py | ||
---|---|---|
1 |
# Copyright 2012 GRNET S.A. All rights reserved. |
|
1 |
# Copyright 2012, 2013 GRNET S.A. All rights reserved.
|
|
2 | 2 |
# |
3 | 3 |
# Redistribution and use in source and binary forms, with or |
4 | 4 |
# without modification, are permitted provided that the following |
... | ... | |
34 | 34 |
from django.core.management.base import BaseCommand |
35 | 35 |
from optparse import make_option |
36 | 36 |
|
37 |
from synnefo.quotas.util import get_db_holdings, get_quotaholder_holdings |
|
37 |
from synnefo.quotas import DEFAULT_SOURCE |
|
38 |
from synnefo.quotas.util import (get_db_holdings, get_quotaholder_holdings, |
|
39 |
transform_quotas) |
|
38 | 40 |
from synnefo.webproject.management.utils import pprint_table |
39 | 41 |
|
40 | 42 |
|
... | ... | |
61 | 63 |
# Get info from DB |
62 | 64 |
db_holdings = get_db_holdings(users) |
63 | 65 |
users = db_holdings.keys() |
64 |
qh_holdings = get_quotaholder_holdings(users)
|
|
66 |
qh_holdings = get_quotaholder_holdings(userid)
|
|
65 | 67 |
qh_users = qh_holdings.keys() |
66 | 68 |
|
67 | 69 |
if len(qh_users) < len(users): |
... | ... | |
73 | 75 |
unsynced = [] |
74 | 76 |
for user in users: |
75 | 77 |
db = db_holdings[user] |
76 |
qh = qh_holdings[user] |
|
77 |
if not self.verify_resources(user, db.keys(), qh.keys()): |
|
78 |
continue |
|
78 |
qh_all = qh_holdings[user] |
|
79 |
# Assuming only one source |
|
80 |
qh = qh_all[DEFAULT_SOURCE] |
|
81 |
qh = transform_quotas(qh) |
|
79 | 82 |
|
80 |
for res in db.keys(): |
|
81 |
if db[res] != qh[res]: |
|
82 |
unsynced.append((user, res, str(db[res]), str(qh[res]))) |
|
83 |
for resource, (value, value1) in qh.iteritems: |
|
84 |
db_value = db.pop(resource, None) |
|
85 |
if value != value1: |
|
86 |
write("Commission pending for %s" |
|
87 |
% str((user, resource))) |
|
88 |
continue |
|
89 |
if db_value is None: |
|
90 |
write("Resource %s exists in QH for %s but not in DB\n" |
|
91 |
% (resource, user)) |
|
92 |
elif db_value != value: |
|
93 |
data = (user, resource, str(db_value), str(value)) |
|
94 |
unsynced.append(data) |
|
95 |
|
|
96 |
for resource, db_value in db.iteritems(): |
|
97 |
write("Resource %s exists in DB for %s but not in QH\n" |
|
98 |
% (resource, user)) |
|
83 | 99 |
|
84 | 100 |
if unsynced: |
85 | 101 |
pprint_table(self.stderr, unsynced, headers) |
86 |
|
|
87 |
def verify_resources(self, user, db_resources, qh_resources): |
|
88 |
write = self.stderr.write |
|
89 |
db_res = set(db_resources) |
|
90 |
qh_res = set(qh_resources) |
|
91 |
if qh_res == db_res: |
|
92 |
return True |
|
93 |
db_extra = db_res - qh_res |
|
94 |
if db_extra: |
|
95 |
for res in db_extra: |
|
96 |
write("Resource %s exists in DB for %s but not in QH\n" |
|
97 |
% (res, user)) |
|
98 |
qh_extra = qh_res - db_res |
|
99 |
if qh_extra: |
|
100 |
for res in qh_extra: |
|
101 |
write("Resource %s exists in QH for %s but not in DB\n" |
|
102 |
% (res, user)) |
|
103 |
return False |
b/snf-cyclades-app/synnefo/quotas/util.py | ||
---|---|---|
1 |
# Copyright 2012 GRNET S.A. All rights reserved. |
|
1 |
# Copyright 2012, 2013 GRNET S.A. All rights reserved.
|
|
2 | 2 |
# |
3 | 3 |
# Redistribution and use in source and binary forms, with or |
4 | 4 |
# without modification, are permitted provided that the following |
... | ... | |
34 | 34 |
from django.db.models import Sum, Count |
35 | 35 |
|
36 | 36 |
from synnefo.db.models import VirtualMachine, Network |
37 |
from synnefo.quotas import get_quota_holder, NoEntityError
|
|
37 |
from synnefo.quotas import Quotaholder, ASTAKOS_TOKEN
|
|
38 | 38 |
|
39 | 39 |
|
40 | 40 |
def get_db_holdings(users=None): |
... | ... | |
74 | 74 |
return holdings |
75 | 75 |
|
76 | 76 |
|
77 |
def get_quotaholder_holdings(users=[]):
|
|
78 |
"""Get holdings from Quotaholder.
|
|
77 |
def get_quotaholder_holdings(user=None):
|
|
78 |
"""Get quotas from Quotaholder for all Cyclades resources.
|
|
79 | 79 |
|
80 |
If the entity for the user does not exist in quotaholder, no holding |
|
81 |
is returned. |
|
80 |
Returns quotas for all users, unless a single user is specified. |
|
82 | 81 |
""" |
83 |
users = filter(lambda u: not u is None, users) |
|
84 |
holdings = {} |
|
85 |
with get_quota_holder() as qh: |
|
86 |
list_holdings = [(user, "1") for user in users] |
|
87 |
(qh_holdings, rejected) = qh.list_holdings(context={}, |
|
88 |
list_holdings=list_holdings) |
|
89 |
found_users = filter(lambda u: not u in rejected, users) |
|
90 |
for user, user_holdings in zip(found_users, qh_holdings): |
|
91 |
if not user_holdings: |
|
92 |
continue |
|
93 |
for h in user_holdings: |
|
94 |
assert(h[0] == user) |
|
95 |
user_holdings = filter(lambda x: x[1].startswith("cyclades."), |
|
96 |
user_holdings) |
|
97 |
holdings[user] = dict(map(decode_holding, user_holdings)) |
|
98 |
return holdings |
|
82 |
qh = Quotaholder.get() |
|
83 |
return qh.get_service_quotas(ASTAKOS_TOKEN, user) |
|
99 | 84 |
|
100 | 85 |
|
101 |
def decode_holding(holding): |
|
102 |
entity, resource, imported, exported, returned, released = holding |
|
103 |
res = resource.replace("cyclades.", "") |
|
104 |
return (res, imported - exported + returned - released) |
|
86 |
def transform_quotas(quotas): |
|
87 |
d = {} |
|
88 |
for resource, counters in quotas.iteritems(): |
|
89 |
res = resource.replace("cyclades.", "") |
|
90 |
available = counters['available'] |
|
91 |
limit = counters['limit'] |
|
92 |
used = counters['used'] |
|
93 |
used_max = limit - available |
|
94 |
d[res] = (used, used_max) |
|
95 |
return d |
Also available in: Unified diff