Statistics
| Branch: | Tag: | Revision:

root / snf-tools / synnefo_tools / burnin / common.py @ 3e5bbd85

History | View | Annotate | Download (21.8 kB)

1
# Copyright 2013 GRNET S.A. All rights reserved.
2
#
3
# Redistribution and use in source and binary forms, with or
4
# without modification, are permitted provided that the following
5
# conditions are met:
6
#
7
#   1. Redistributions of source code must retain the above
8
#      copyright notice, this list of conditions and the following
9
#      disclaimer.
10
#
11
#   2. Redistributions in binary form must reproduce the above
12
#      copyright notice, this list of conditions and the following
13
#      disclaimer in the documentation and/or other materials
14
#      provided with the distribution.
15
#
16
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27
# POSSIBILITY OF SUCH DAMAGE.
28
#
29
# The views and conclusions contained in the software and
30
# documentation are those of the authors and should not be
31
# interpreted as representing official policies, either expressed
32
# or implied, of GRNET S.A.
33

    
34
"""
35
Common utils for burnin tests
36

37
"""
38

    
39
import os
40
import re
41
import shutil
42
import unittest
43
import datetime
44
import tempfile
45
import traceback
46

    
47
from kamaki.clients.cyclades import CycladesClient
48
from kamaki.clients.astakos import AstakosClient
49
from kamaki.clients.compute import ComputeClient
50
from kamaki.clients.pithos import PithosClient
51
from kamaki.clients.image import ImageClient
52

    
53
from synnefo_tools.burnin.logger import Log
54

    
55

    
56
# --------------------------------------------------------------------
57
# Global variables
58
logger = None  # Invalid constant name. pylint: disable-msg=C0103
59
SNF_TEST_PREFIX = "snf-test-"
60
CONNECTION_RETRY_LIMIT = 2
61
SYSTEM_USERS = ["images@okeanos.grnet.gr", "images@demo.synnefo.org"]
62
KB = 2**10
63
MB = 2**20
64
GB = 2**30
65

    
66

    
67
# --------------------------------------------------------------------
68
# BurninTestResult class
69
class BurninTestResult(unittest.TestResult):
70
    """Modify the TextTestResult class"""
71
    def __init__(self):
72
        super(BurninTestResult, self).__init__()
73

    
74
        # Test parameters
75
        self.failfast = True
76

    
77
    def startTest(self, test):  # noqa
78
        """Called when the test case test is about to be run"""
79
        super(BurninTestResult, self).startTest(test)
80
        logger.log(test.__class__.__name__, test.shortDescription())
81

    
82
    # Method could be a function. pylint: disable-msg=R0201
83
    def _test_failed(self, test, err):
84
        """Test failed"""
85
        # Get class name
86
        if test.__class__.__name__ == "_ErrorHolder":
87
            class_name = test.id().split('.')[-1].rstrip(')')
88
        else:
89
            class_name = test.__class__.__name__
90
        err_msg = str(test) + "... failed (%s)."
91
        timestamp = datetime.datetime.strftime(
92
            datetime.datetime.now(), "%a %b %d %Y %H:%M:%S")
93
        logger.error(class_name, err_msg, timestamp)
94
        (err_type, err_value, err_trace) = err
95
        trcback = traceback.format_exception(err_type, err_value, err_trace)
96
        logger.info(class_name, trcback)
97

    
98
    def addError(self, test, err):  # noqa
99
        """Called when the test case test raises an unexpected exception"""
100
        super(BurninTestResult, self).addError(test, err)
101
        self._test_failed(test, err)
102

    
103
    def addFailure(self, test, err):  # noqa
104
        """Called when the test case test signals a failure"""
105
        super(BurninTestResult, self).addFailure(test, err)
106
        self._test_failed(test, err)
107

    
108

    
109
# --------------------------------------------------------------------
110
# Helper Classes
111
# Too few public methods. pylint: disable-msg=R0903
112
# Too many instance attributes. pylint: disable-msg=R0902
113
class Clients(object):
114
    """Our kamaki clients"""
115
    auth_url = None
116
    token = None
117
    # Astakos
118
    astakos = None
119
    retry = CONNECTION_RETRY_LIMIT
120
    # Compute
121
    compute = None
122
    compute_url = None
123
    # Cyclades
124
    cyclades = None
125
    # Pithos
126
    pithos = None
127
    pithos_url = None
128
    # Image
129
    image = None
130
    image_url = None
131

    
132
    def initialize_clients(self):
133
        """Initialize all the Kamaki Clients"""
134
        self.astakos = AstakosClient(self.auth_url, self.token)
135
        self.astakos.CONNECTION_RETRY_LIMIT = self.retry
136

    
137
        self.compute_url = \
138
            self.astakos.get_service_endpoints('compute')['publicURL']
139
        self.compute = ComputeClient(self.compute_url, self.token)
140
        self.compute.CONNECTION_RETRY_LIMIT = self.retry
141

    
142
        self.cyclades = CycladesClient(self.compute_url, self.token)
143
        self.cyclades.CONNECTION_RETRY_LIMIT = self.retry
144

    
145
        self.pithos_url = self.astakos.\
146
            get_service_endpoints('object-store')['publicURL']
147
        self.pithos = PithosClient(self.pithos_url, self.token)
148
        self.pithos.CONNECTION_RETRY_LIMIT = self.retry
149

    
150
        self.image_url = \
151
            self.astakos.get_service_endpoints('image')['publicURL']
152
        self.image = ImageClient(self.image_url, self.token)
153
        self.image.CONNECTION_RETRY_LIMIT = self.retry
154

    
155

    
156
class Proper(object):
157
    """A descriptor used by tests implementing the TestCase class
158

159
    Since each instance of the TestCase will only be used to run a single
160
    test method (a new fixture is created for each test) the attributes can
161
    not be saved in the class instances. Instead we use descriptors.
162

163
    """
164
    def __init__(self, value=None):
165
        self.val = value
166

    
167
    def __get__(self, obj, objtype=None):
168
        return self.val
169

    
170
    def __set__(self, obj, value):
171
        self.val = value
172

    
173

    
174
# --------------------------------------------------------------------
175
# BurninTests class
176
# Too many public methods (45/20). pylint: disable-msg=R0904
177
class BurninTests(unittest.TestCase):
178
    """Common class that all burnin tests should implement"""
179
    clients = Clients()
180
    run_id = None
181
    use_ipv6 = None
182
    action_timeout = None
183
    action_warning = None
184
    query_interval = None
185
    system_user = None
186
    images = None
187
    flavors = None
188
    delete_stale = False
189

    
190
    quotas = Proper(value=None)
191

    
192
    @classmethod
193
    def setUpClass(cls):  # noqa
194
        """Initialize BurninTests"""
195
        cls.suite_name = cls.__name__
196
        logger.testsuite_start(cls.suite_name)
197

    
198
        # Set test parameters
199
        cls.longMessage = True
200

    
201
    def test_000_clients_setup(self):
202
        """Initializing astakos/cyclades/pithos clients"""
203
        # Update class attributes
204
        self.clients.initialize_clients()
205
        self.info("Astakos auth url is %s", self.clients.auth_url)
206
        self.info("Cyclades url is %s", self.clients.compute_url)
207
        self.info("Pithos url is %s", self.clients.pithos_url)
208
        self.info("Image url is %s", self.clients.image_url)
209

    
210
        self.quotas = self._get_quotas()
211
        self.info("  Disk usage is %s bytes",
212
                  self.quotas['system']['cyclades.disk']['usage'])
213
        self.info("  VM usage is %s",
214
                  self.quotas['system']['cyclades.vm']['usage'])
215
        self.info("  DiskSpace usage is %s bytes",
216
                  self.quotas['system']['pithos.diskspace']['usage'])
217
        self.info("  Ram usage is %s bytes",
218
                  self.quotas['system']['cyclades.ram']['usage'])
219
        self.info("  CPU usage is %s",
220
                  self.quotas['system']['cyclades.cpu']['usage'])
221
        self.info("  Network usage is %s",
222
                  self.quotas['system']['cyclades.network.private']['usage'])
223

    
224
    # ----------------------------------
225
    # Loggers helper functions
226
    def log(self, msg, *args):
227
        """Pass the section value to logger"""
228
        logger.log(self.suite_name, msg, *args)
229

    
230
    def info(self, msg, *args):
231
        """Pass the section value to logger"""
232
        logger.info(self.suite_name, msg, *args)
233

    
234
    def debug(self, msg, *args):
235
        """Pass the section value to logger"""
236
        logger.debug(self.suite_name, msg, *args)
237

    
238
    def warning(self, msg, *args):
239
        """Pass the section value to logger"""
240
        logger.warning(self.suite_name, msg, *args)
241

    
242
    def error(self, msg, *args):
243
        """Pass the section value to logger"""
244
        logger.error(self.suite_name, msg, *args)
245

    
246
    # ----------------------------------
247
    # Helper functions that every testsuite may need
248
    def _get_uuid(self):
249
        """Get our uuid"""
250
        authenticate = self.clients.astakos.authenticate()
251
        uuid = authenticate['access']['user']['id']
252
        self.info("User's uuid is %s", uuid)
253
        return uuid
254

    
255
    def _get_username(self):
256
        """Get our User Name"""
257
        authenticate = self.clients.astakos.authenticate()
258
        username = authenticate['access']['user']['name']
259
        self.info("User's name is %s", username)
260
        return username
261

    
262
    def _create_tmp_directory(self):
263
        """Create a tmp directory
264

265
        In my machine /tmp has not enough space for an image
266
        to be saves, so we are going to use the current directory.
267

268
        """
269
        temp_dir = tempfile.mkdtemp(dir=os.getcwd())
270
        self.info("Temp directory %s created", temp_dir)
271
        return temp_dir
272

    
273
    def _remove_tmp_directory(self, tmp_dir):
274
        """Remove a tmp directory"""
275
        try:
276
            shutil.rmtree(tmp_dir)
277
            self.info("Temp directory %s deleted", tmp_dir)
278
        except OSError:
279
            pass
280

    
281
    def _get_uuid_of_system_user(self):
282
        """Get the uuid of the system user
283

284
        This is the user that upload the 'official' images.
285

286
        """
287
        self.info("Getting the uuid of the system user")
288
        system_users = None
289
        if self.system_user is not None:
290
            parsed_su = parse_typed_option(self.system_user)
291
            if parsed_su is None:
292
                msg = "Invalid system-user format: %s. Must be [id|name]:.+"
293
                self.warning(msg, self.system_user)
294
            else:
295
                su_type, su_value = parsed_su
296
                if su_type == "name":
297
                    system_users = [su_value]
298
                elif su_type == "id":
299
                    self.info("System user's uuid is %s", su_value)
300
                    return su_value
301
                else:
302
                    self.error("Unrecognized system-user type %s", su_type)
303
                    self.fail("Unrecognized system-user type")
304

    
305
        if system_users is None:
306
            system_users = SYSTEM_USERS
307

    
308
        uuids = self.clients.astakos.usernames2uuids(system_users)
309
        for su_name in system_users:
310
            self.info("Trying username %s", su_name)
311
            if su_name in uuids:
312
                self.info("System user's uuid is %s", uuids[su_name])
313
                return uuids[su_name]
314

    
315
        self.warning("No system user found")
316
        return None
317

    
318
    def _skip_if(self, condition, msg):
319
        """Skip tests"""
320
        if condition:
321
            self.info("Test skipped: %s" % msg)
322
            self.skipTest(msg)
323

    
324
    # ----------------------------------
325
    # Flavors
326
    def _get_list_of_flavors(self, detail=False):
327
        """Get (detailed) list of flavors"""
328
        if detail:
329
            self.info("Getting detailed list of flavors")
330
        else:
331
            self.info("Getting simple list of flavors")
332
        flavors = self.clients.compute.list_flavors(detail=detail)
333
        return flavors
334

    
335
    def _find_flavors(self, patterns, flavors=None):
336
        """Find a list of suitable flavors to use
337

338
        The patterns is a list of `typed_options'. A list of all flavors
339
        matching this patterns will be returned.
340

341
        """
342
        if flavors is None:
343
            flavors = self._get_list_of_flavors(detail=True)
344

    
345
        ret_flavors = []
346
        for ptrn in patterns:
347
            parsed_ptrn = parse_typed_option(ptrn)
348
            if parsed_ptrn is None:
349
                msg = "Invalid flavor format: %s. Must be [id|name]:.+"
350
                self.warning(msg, ptrn)
351
                continue
352
            flv_type, flv_value = parsed_ptrn
353
            if flv_type == "name":
354
                # Filter flavor by name
355
                msg = "Trying to find a flavor with name %s"
356
                self.info(msg, flv_value)
357
                filtered_flvs = \
358
                    [f for f in flavors if
359
                     re.search(flv_value, f['name'], flags=re.I) is not None]
360
            elif flv_type == "id":
361
                # Filter flavors by id
362
                msg = "Trying to find a flavor with id %s"
363
                self.info(msg, flv_value)
364
                filtered_flvs = \
365
                    [f for f in flavors if str(f['id']) == flv_value]
366
            else:
367
                self.error("Unrecognized flavor type %s", flv_type)
368
                self.fail("Unrecognized flavor type")
369

    
370
            # Append and continue
371
            ret_flavors.extend(filtered_flvs)
372

    
373
        self.assertGreater(len(ret_flavors), 0,
374
                           "No matching flavors found")
375
        return ret_flavors
376

    
377
    # ----------------------------------
378
    # Images
379
    def _get_list_of_images(self, detail=False):
380
        """Get (detailed) list of images"""
381
        if detail:
382
            self.info("Getting detailed list of images")
383
        else:
384
            self.info("Getting simple list of images")
385
        images = self.clients.image.list_public(detail=detail)
386
        # Remove images registered by burnin
387
        images = [img for img in images
388
                  if not img['name'].startswith(SNF_TEST_PREFIX)]
389
        return images
390

    
391
    def _get_list_of_sys_images(self, images=None):
392
        """Get (detailed) list of images registered by system user or by me"""
393
        self.info("Getting list of images registered by system user or by me")
394
        if images is None:
395
            images = self._get_list_of_images(detail=True)
396

    
397
        su_uuid = self._get_uuid_of_system_user()
398
        my_uuid = self._get_uuid()
399
        ret_images = [i for i in images
400
                      if i['owner'] == su_uuid or i['owner'] == my_uuid]
401

    
402
        return ret_images
403

    
404
    def _find_images(self, patterns, images=None):
405
        """Find a list of suitable images to use
406

407
        The patterns is a list of `typed_options'. A list of all images
408
        matching this patterns will be returned.
409

410
        """
411
        if images is None:
412
            images = self._get_list_of_sys_images()
413

    
414
        ret_images = []
415
        for ptrn in patterns:
416
            parsed_ptrn = parse_typed_option(ptrn)
417
            if parsed_ptrn is None:
418
                msg = "Invalid image format: %s. Must be [id|name]:.+"
419
                self.warning(msg, ptrn)
420
                continue
421
            img_type, img_value = parsed_ptrn
422
            if img_type == "name":
423
                # Filter image by name
424
                msg = "Trying to find an image with name %s"
425
                self.info(msg, img_value)
426
                filtered_imgs = \
427
                    [i for i in images if
428
                     re.search(img_value, i['name'], flags=re.I) is not None]
429
            elif img_type == "id":
430
                # Filter images by id
431
                msg = "Trying to find an image with id %s"
432
                self.info(msg, img_value)
433
                filtered_imgs = \
434
                    [i for i in images if
435
                     i['id'].lower() == img_value.lower()]
436
            else:
437
                self.error("Unrecognized image type %s", img_type)
438
                self.fail("Unrecognized image type")
439

    
440
            # Append and continue
441
            ret_images.extend(filtered_imgs)
442

    
443
        self.assertGreater(len(ret_images), 0,
444
                           "No matching images found")
445
        return ret_images
446

    
447
    # ----------------------------------
448
    # Pithos
449
    def _set_pithos_account(self, account):
450
        """Set the Pithos account"""
451
        assert account, "No pithos account was given"
452

    
453
        self.info("Setting Pithos account to %s", account)
454
        self.clients.pithos.account = account
455

    
456
    def _set_pithos_container(self, container):
457
        """Set the Pithos container"""
458
        assert container, "No pithos container was given"
459

    
460
        self.info("Setting Pithos container to %s", container)
461
        self.clients.pithos.container = container
462

    
463
    def _get_list_of_containers(self, account=None):
464
        """Get list of containers"""
465
        if account is not None:
466
            self._set_pithos_account(account)
467
        self.info("Getting list of containers")
468
        return self.clients.pithos.list_containers()
469

    
470
    def _create_pithos_container(self, container):
471
        """Create a pithos container
472

473
        If the container exists, nothing will happen
474

475
        """
476
        assert container, "No pithos container was given"
477

    
478
        self.info("Creating pithos container %s", container)
479
        self.clients.pithos.container = container
480
        self.clients.pithos.container_put()
481

    
482
    # ----------------------------------
483
    # Quotas
484
    def _get_quotas(self):
485
        """Get quotas"""
486
        self.info("Getting quotas")
487
        astakos_client = self.clients.astakos.get_client()
488
        return astakos_client.get_quotas()
489

    
490
    # Invalid argument name. pylint: disable-msg=C0103
491
    # Too many arguments. pylint: disable-msg=R0913
492
    def _check_quotas(self, disk=None, vm=None, diskspace=None,
493
                      ram=None, cpu=None, network=None):
494
        """Check that quotas' changes are consistent"""
495
        assert any(v is None for v in
496
                   [disk, vm, diskspace, ram, cpu, network]), \
497
            "_check_quotas require arguments"
498

    
499
        self.info("Check that quotas' changes are consistent")
500
        old_quotas = self.quotas
501
        new_quotas = self._get_quotas()
502
        self.quotas = new_quotas
503

    
504
        # Check Disk usage
505
        self._check_quotas_aux(
506
            old_quotas, new_quotas, 'cyclades.disk', disk)
507
        # Check VM usage
508
        self._check_quotas_aux(
509
            old_quotas, new_quotas, 'cyclades.vm', vm)
510
        # Check DiskSpace usage
511
        self._check_quotas_aux(
512
            old_quotas, new_quotas, 'pithos.diskspace', diskspace)
513
        # Check Ram usage
514
        self._check_quotas_aux(
515
            old_quotas, new_quotas, 'cyclades.ram', ram)
516
        # Check CPU usage
517
        self._check_quotas_aux(
518
            old_quotas, new_quotas, 'cyclades.cpu', cpu)
519
        # Check Network usage
520
        self._check_quotas_aux(
521
            old_quotas, new_quotas, 'cyclades.network.private', network)
522

    
523
    def _check_quotas_aux(self, old_quotas, new_quotas, resource, value):
524
        """Auxiliary function for _check_quotas"""
525
        old_value = old_quotas['system'][resource]['usage']
526
        new_value = new_quotas['system'][resource]['usage']
527
        if value is not None:
528
            assert isinstance(value, int), \
529
                "%s value has to be integer" % resource
530
            old_value += value
531
        self.assertEqual(old_value, new_value,
532
                         "%s quotas don't match" % resource)
533

    
534

    
535
# --------------------------------------------------------------------
536
# Initialize Burnin
537
def initialize(opts, testsuites, stale_testsuites):
538
    """Initalize burnin
539

540
    Initialize our logger and burnin state
541

542
    """
543
    # Initialize logger
544
    global logger  # Using global statement. pylint: disable-msg=C0103,W0603
545
    curr_time = datetime.datetime.now()
546
    logger = Log(opts.log_folder, verbose=opts.verbose,
547
                 use_colors=opts.use_colors, in_parallel=False,
548
                 log_level=opts.log_level, curr_time=curr_time)
549

    
550
    # Initialize clients
551
    Clients.auth_url = opts.auth_url
552
    Clients.token = opts.token
553

    
554
    # Pass the rest options to BurninTests
555
    BurninTests.use_ipv6 = opts.use_ipv6
556
    BurninTests.action_timeout = opts.action_timeout
557
    BurninTests.action_warning = opts.action_warning
558
    BurninTests.query_interval = opts.query_interval
559
    BurninTests.system_user = opts.system_user
560
    BurninTests.flavors = opts.flavors
561
    BurninTests.images = opts.images
562
    BurninTests.delete_stale = opts.delete_stale
563
    BurninTests.run_id = SNF_TEST_PREFIX + \
564
        datetime.datetime.strftime(curr_time, "%Y%m%d%H%M%S")
565

    
566
    # Choose tests to run
567
    if opts.show_stale:
568
        # We will run the stale_testsuites
569
        return (stale_testsuites, True)
570

    
571
    if opts.tests != "all":
572
        testsuites = opts.tests
573
    if opts.exclude_tests is not None:
574
        testsuites = [tsuite for tsuite in testsuites
575
                      if tsuite not in opts.exclude_tests]
576

    
577
    return (testsuites, opts.failfast)
578

    
579

    
580
# --------------------------------------------------------------------
581
# Run Burnin
582
def run_burnin(testsuites, failfast=False):
583
    """Run burnin testsuites"""
584
    global logger  # Using global. pylint: disable-msg=C0103,W0603,W0602
585

    
586
    success = True
587
    for tcase in testsuites:
588
        was_success = run_test(tcase)
589
        success = success and was_success
590
        if failfast and not success:
591
            break
592

    
593
    # Clean up our logger
594
    del(logger)
595

    
596
    # Return
597
    return 0 if success else 1
598

    
599

    
600
def run_test(tcase):
601
    """Run a testcase"""
602
    tsuite = unittest.TestLoader().loadTestsFromTestCase(tcase)
603
    results = tsuite.run(BurninTestResult())
604

    
605
    return was_successful(tcase.__name__, results.wasSuccessful())
606

    
607

    
608
# --------------------------------------------------------------------
609
# Helper functions
610
def was_successful(tsuite, success):
611
    """Handle whether a testsuite was succesful or not"""
612
    if success:
613
        logger.testsuite_success(tsuite)
614
        return True
615
    else:
616
        logger.testsuite_failure(tsuite)
617
        return False
618

    
619

    
620
def parse_typed_option(value):
621
    """Parse typed options (flavors and images)
622

623
    The options are in the form 'id:123-345' or 'name:^Debian Base$'
624

625
    """
626
    try:
627
        [type_, val] = value.strip().split(':')
628
        if type_ not in ["id", "name"]:
629
            raise ValueError
630
        return type_, val
631
    except ValueError:
632
        return None