Statistics
| Branch: | Tag: | Revision:

root / snf-tools / test_suite.py @ e72bcf60

History | View | Annotate | Download (32.4 kB)

1
#!/usr/bin/env python
2

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

    
36
"""Perform integration testing on a running Synnefo deployment"""
37

    
38
import __main__
39
import datetime
40
import inspect
41
import logging
42
import os
43
import paramiko
44
import prctl
45
import subprocess
46
import signal
47
import socket
48
import struct
49
import sys
50
import time
51

    
52
from IPy import IP
53
from multiprocessing import Process, Queue
54
from random import choice
55

    
56
from kamaki.client import Client, ClientError
57
from vncauthproxy.d3des import generate_response as d3des_generate_response
58

    
59
# Use backported unittest functionality if Python < 2.7
60
try:
61
    import unittest2 as unittest
62
except ImportError:
63
    if sys.version_info < (2, 7):
64
        raise Exception("The unittest2 package is required for Python < 2.7")
65
    import unittest
66

    
67

    
68
API = None
69
TOKEN = None
70
DEFAULT_API = "http://dev67.dev.grnet.gr:8000/api/v1.1"
71
DEFAULT_TOKEN = "46e427d657b20defe352804f0eb6f8a2"
72
# A unique id identifying this test run
73
TEST_RUN_ID = datetime.datetime.strftime(datetime.datetime.now(),
74
                                         "%Y%m%d%H%M%S")
75
SNF_TEST_PREFIX = "snf-test-"
76

    
77
# Setup logging (FIXME - verigak)
78
logging.basicConfig(format="%(message)s")
79
log = logging.getLogger("snf-test")
80
log.setLevel(logging.INFO)
81

    
82

    
83
class UnauthorizedTestCase(unittest.TestCase):
84
    def test_unauthorized_access(self):
85
        """Test access without a valid token fails"""
86
        c = Client(API, "123")
87
        with self.assertRaises(ClientError) as cm:
88
            c.list_servers()
89
        self.assertEqual(cm.exception.status, 401)
90

    
91

    
92
class ImagesTestCase(unittest.TestCase):
93
    """Test image lists for consistency"""
94
    @classmethod
95
    def setUpClass(cls):
96
        """Initialize kamaki, get (detailed) list of images"""
97
        log.info("Getting simple and detailed list of images")
98
        cls.client = Client(API, TOKEN)
99
        cls.images = cls.client.list_images()
100
        cls.dimages = cls.client.list_images(detail=True)
101

    
102
    def test_001_list_images(self):
103
        """Test image list actually returns images"""
104
        self.assertGreater(len(self.images), 0)
105

    
106
    def test_002_list_images_detailed(self):
107
        """Test detailed image list is the same length as list"""
108
        self.assertEqual(len(self.dimages), len(self.images))
109

    
110
    def test_003_same_image_names(self):
111
        """Test detailed and simple image list contain same names"""
112
        names = sorted(map(lambda x: x["name"], self.images))
113
        dnames = sorted(map(lambda x: x["name"], self.dimages))
114
        self.assertEqual(names, dnames)
115

    
116
    def test_004_unique_image_names(self):
117
        """Test images have unique names"""
118
        names = sorted(map(lambda x: x["name"], self.images))
119
        self.assertEqual(sorted(list(set(names))), names)
120

    
121
    def test_005_image_metadata(self):
122
        """Test every image has specific metadata defined"""
123
        keys = frozenset(["OS", "description", "size"])
124
        for i in self.dimages:
125
            self.assertTrue(keys.issubset(i["metadata"]["values"].keys()))
126

    
127

    
128
class FlavorsTestCase(unittest.TestCase):
129
    """Test flavor lists for consistency"""
130
    @classmethod
131
    def setUpClass(cls):
132
        """Initialize kamaki, get (detailed) list of flavors"""
133
        log.info("Getting simple and detailed list of flavors")
134
        cls.client = Client(API, TOKEN)
135
        cls.flavors = cls.client.list_flavors()
136
        cls.dflavors = cls.client.list_flavors(detail=True)
137

    
138
    def test_001_list_flavors(self):
139
        """Test flavor list actually returns flavors"""
140
        self.assertGreater(len(self.flavors), 0)
141

    
142
    def test_002_list_flavors_detailed(self):
143
        """Test detailed flavor list is the same length as list"""
144
        self.assertEquals(len(self.dflavors), len(self.flavors))
145

    
146
    def test_003_same_flavor_names(self):
147
        """Test detailed and simple flavor list contain same names"""
148
        names = sorted(map(lambda x: x["name"], self.flavors))
149
        dnames = sorted(map(lambda x: x["name"], self.dflavors))
150
        self.assertEqual(names, dnames)
151

    
152
    def test_004_unique_flavor_names(self):
153
        """Test flavors have unique names"""
154
        names = sorted(map(lambda x: x["name"], self.flavors))
155
        self.assertEqual(sorted(list(set(names))), names)
156

    
157
    def test_005_well_formed_flavor_names(self):
158
        """Test flavors have names of the form CxxRyyDzz
159

160
        Where xx is vCPU count, yy is RAM in MiB, zz is Disk in GiB
161

162
        """
163
        for f in self.dflavors:
164
            self.assertEqual("C%dR%dD%d" % (f["cpu"], f["ram"], f["disk"]),
165
                             f["name"],
166
                             "Flavor %s does not match its specs." % f["name"])
167

    
168

    
169
class ServersTestCase(unittest.TestCase):
170
    """Test server lists for consistency"""
171
    @classmethod
172
    def setUpClass(cls):
173
        """Initialize kamaki, get (detailed) list of servers"""
174
        log.info("Getting simple and detailed list of servers")
175
        cls.client = Client(API, TOKEN)
176
        cls.servers = cls.client.list_servers()
177
        cls.dservers = cls.client.list_servers(detail=True)
178

    
179
    def test_001_list_servers(self):
180
        """Test server list actually returns servers"""
181
        self.assertGreater(len(self.servers), 0)
182

    
183
    def test_002_list_servers_detailed(self):
184
        """Test detailed server list is the same length as list"""
185
        self.assertEqual(len(self.dservers), len(self.servers))
186

    
187
    def test_003_same_server_names(self):
188
        """Test detailed and simple flavor list contain same names"""
189
        names = sorted(map(lambda x: x["name"], self.servers))
190
        dnames = sorted(map(lambda x: x["name"], self.dservers))
191
        self.assertEqual(names, dnames)
192

    
193

    
194
# This class gets replicated into actual TestCases dynamically
195
class SpawnServerTestCase(unittest.TestCase):
196
    """Test scenario for server of the specified image"""
197

    
198
    @classmethod
199
    def setUpClass(cls):
200
        """Initialize a kamaki instance"""
201
        log.info("Spawning server for image `%s'", cls.imagename)
202
        cls.client = Client(API, TOKEN)
203

    
204
    def _get_ipv4(self, server):
205
        """Get the public IPv4 of a server from the detailed server info"""
206
        public_addrs = filter(lambda x: x["id"] == "public",
207
                              server["addresses"]["values"])
208
        self.assertEqual(len(public_addrs), 1)
209
        ipv4_addrs = filter(lambda x: x["version"] == 4,
210
                            public_addrs[0]["values"])
211
        self.assertEqual(len(ipv4_addrs), 1)
212
        return ipv4_addrs[0]["addr"]
213

    
214
    def _get_ipv6(self, server):
215
        """Get the public IPv6 of a server from the detailed server info"""
216
        public_addrs = filter(lambda x: x["id"] == "public",
217
                              server["addresses"]["values"])
218
        self.assertEqual(len(public_addrs), 1)
219
        ipv6_addrs = filter(lambda x: x["version"] == 6,
220
                            public_addrs[0]["values"])
221
        self.assertEqual(len(ipv6_addrs), 1)
222
        return ipv6_addrs[0]["addr"]
223

    
224
    def _connect_loginname(self, os):
225
        """Return the login name for connections based on the server OS"""
226
        if os in ("ubuntu", "kubuntu", "fedora"):
227
            return "user"
228
        elif os == "windows":
229
            return "Administrator"
230
        else:
231
            return "root"
232

    
233
    def _verify_server_status(self, current_status, new_status):
234
        """Verify a server has switched to a specified status"""
235
        server = self.client.get_server_details(self.serverid)
236
        self.assertIn(server["status"], (current_status, new_status))
237
        if server["status"] not in (current_status, new_status):
238
            return None  # Do not raise exception, return so the test fails
239
        self.assertEquals(server["status"], new_status)
240

    
241
    def _get_connected_tcp_socket(self, family, host, port):
242
        """Get a connected socket from the specified family to host:port"""
243
        sock = None
244
        for res in \
245
            socket.getaddrinfo(host, port, family, socket.SOCK_STREAM, 0,
246
                               socket.AI_PASSIVE):
247
            af, socktype, proto, canonname, sa = res
248
            try:
249
                sock = socket.socket(af, socktype, proto)
250
            except socket.error as msg:
251
                sock = None
252
                continue
253
            try:
254
                sock.connect(sa)
255
            except socket.error as msg:
256
                sock.close()
257
                sock = None
258
                continue
259
        self.assertIsNotNone(sock)
260
        return sock
261

    
262
    def _ping_once(self, ipv6, ip):
263
        """Test server responds to a single IPv4 or IPv6 ping"""
264
        cmd = "ping%s -c 2 -w 3 %s" % ("6" if ipv6 else "", ip)
265
        ping = subprocess.Popen(cmd, shell=True,
266
                                stdout=subprocess.PIPE, stderr=subprocess.PIPE)
267
        (stdout, stderr) = ping.communicate()
268
        ret = ping.wait()
269
        self.assertEquals(ret, 0)
270

    
271
    def _get_hostname_over_ssh(self, hostip, username, password):
272
        ssh = paramiko.SSHClient()
273
        ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
274
        try:
275
            ssh.connect(hostip, username=username, password=password)
276
        except socket.error:
277
            raise AssertionError
278
        stdin, stdout, stderr = ssh.exec_command("hostname")
279
        lines = stdout.readlines()
280
        self.assertEqual(len(lines), 1)
281
        return lines[0]
282

    
283
    def _try_until_timeout_expires(self, warn_timeout, fail_timeout,
284
                                   opmsg, callable, *args, **kwargs):
285
        if warn_timeout == fail_timeout:
286
            warn_timeout = fail_timeout + 1
287
        warn_tmout = time.time() + warn_timeout
288
        fail_tmout = time.time() + fail_timeout
289
        while True:
290
            self.assertLess(time.time(), fail_tmout,
291
                            "operation `%s' timed out" % opmsg)
292
            if time.time() > warn_tmout:
293
                log.warning("Server %d: `%s' operation `%s' not done yet",
294
                            self.serverid, self.servername, opmsg)
295
            try:
296
                log.info("%s... " % opmsg)
297
                return callable(*args, **kwargs)
298
            except AssertionError:
299
                pass
300
            time.sleep(self.query_interval)
301

    
302
    def _insist_on_tcp_connection(self, family, host, port):
303
        familystr = {socket.AF_INET: "IPv4", socket.AF_INET6: "IPv6",
304
                     socket.AF_UNSPEC: "Unspecified-IPv4/6"}
305
        msg = "connect over %s to %s:%s" % \
306
              (familystr.get(family, "Unknown"), host, port)
307
        sock = self._try_until_timeout_expires(
308
                self.action_timeout, self.action_timeout,
309
                msg, self._get_connected_tcp_socket,
310
                family, host, port)
311
        return sock
312

    
313
    def _insist_on_status_transition(self, current_status, new_status,
314
                                    fail_timeout, warn_timeout=None):
315
        msg = "Server %d: `%s', waiting for %s -> %s" % \
316
              (self.serverid, self.servername, current_status, new_status)
317
        if warn_timeout is None:
318
            warn_timeout = fail_timeout
319
        self._try_until_timeout_expires(warn_timeout, fail_timeout,
320
                                        msg, self._verify_server_status,
321
                                        current_status, new_status)
322
        # Ensure the status is actually the expected one
323
        server = self.client.get_server_details(self.serverid)
324
        self.assertEquals(server["status"], new_status)
325

    
326
    def _insist_on_ssh_hostname(self, hostip, username, password):
327
        msg = "SSH to %s, as %s/%s" % (hostip, username, password)
328
        hostname = self._try_until_timeout_expires(
329
                self.action_timeout, self.action_timeout,
330
                msg, self._get_hostname_over_ssh,
331
                hostip, username, password)
332

    
333
        # The hostname must be of the form 'prefix-id'
334
        self.assertTrue(hostname.endswith("-%d\n" % self.serverid))
335

    
336
    def _skipIf(self, condition, msg):
337
        if condition:
338
            self.skipTest(msg)
339

    
340
    def test_001_submit_create_server(self):
341
        """Test submit create server request"""
342
        server = self.client.create_server(self.servername, self.flavorid,
343
                                           self.imageid, self.personality)
344
        self.assertEqual(server["name"], self.servername)
345
        self.assertEqual(server["flavorRef"], self.flavorid)
346
        self.assertEqual(server["imageRef"], self.imageid)
347
        self.assertEqual(server["status"], "BUILD")
348

    
349
        # Update class attributes to reflect data on building server
350
        cls = type(self)
351
        cls.serverid = server["id"]
352
        cls.username = None
353
        cls.passwd = server["adminPass"]
354

    
355
    def test_002a_server_is_building_in_list(self):
356
        """Test server is in BUILD state, in server list"""
357
        servers = self.client.list_servers(detail=True)
358
        servers = filter(lambda x: x["name"] == self.servername, servers)
359
        self.assertEqual(len(servers), 1)
360
        server = servers[0]
361
        self.assertEqual(server["name"], self.servername)
362
        self.assertEqual(server["flavorRef"], self.flavorid)
363
        self.assertEqual(server["imageRef"], self.imageid)
364
        self.assertEqual(server["status"], "BUILD")
365

    
366
    def test_002b_server_is_building_in_details(self):
367
        """Test server is in BUILD state, in details"""
368
        server = self.client.get_server_details(self.serverid)
369
        self.assertEqual(server["name"], self.servername)
370
        self.assertEqual(server["flavorRef"], self.flavorid)
371
        self.assertEqual(server["imageRef"], self.imageid)
372
        self.assertEqual(server["status"], "BUILD")
373

    
374
    def test_002c_set_server_metadata(self):
375
        image = self.client.get_image_details(self.imageid)
376
        os = image["metadata"]["values"]["OS"]
377
        loginname = image["metadata"]["values"].get("loginname", None)
378
        self.client.update_server_metadata(self.serverid, OS=os)
379

    
380
        # Determine the username to use for future connections
381
        # to this host
382
        cls = type(self)
383
        cls.username = loginname
384
        if not cls.username:
385
            cls.username = self._connect_loginname(os)
386
        self.assertIsNotNone(cls.username)
387

    
388
    def test_002d_verify_server_metadata(self):
389
        """Test server metadata keys are set based on image metadata"""
390
        servermeta = self.client.get_server_metadata(self.serverid)
391
        imagemeta = self.client.get_image_metadata(self.imageid)
392
        self.assertEqual(servermeta["OS"], imagemeta["OS"])
393

    
394
    def test_003_server_becomes_active(self):
395
        """Test server becomes ACTIVE"""
396
        self._insist_on_status_transition("BUILD", "ACTIVE",
397
                                         self.build_fail, self.build_warning)
398

    
399
    def test_003a_get_server_oob_console(self):
400
        """Test getting OOB server console over VNC
401

402
        Implementation of RFB protocol follows
403
        http://www.realvnc.com/docs/rfbproto.pdf.
404

405
        """
406
        console = self.client.get_server_console(self.serverid)
407
        self.assertEquals(console['type'], "vnc")
408
        sock = self._insist_on_tcp_connection(socket.AF_UNSPEC,
409
                                        console["host"], console["port"])
410

    
411
        # Step 1. ProtocolVersion message (par. 6.1.1)
412
        version = sock.recv(1024)
413
        self.assertEquals(version, 'RFB 003.008\n')
414
        sock.send(version)
415

    
416
        # Step 2. Security (par 6.1.2): Only VNC Authentication supported
417
        sec = sock.recv(1024)
418
        self.assertEquals(list(sec), ['\x01', '\x02'])
419

    
420
        # Step 3. Request VNC Authentication (par 6.1.2)
421
        sock.send('\x02')
422

    
423
        # Step 4. Receive Challenge (par 6.2.2)
424
        challenge = sock.recv(1024)
425
        self.assertEquals(len(challenge), 16)
426

    
427
        # Step 5. DES-Encrypt challenge, use password as key (par 6.2.2)
428
        response = d3des_generate_response(
429
            (console["password"] + '\0' * 8)[:8], challenge)
430
        sock.send(response)
431

    
432
        # Step 6. SecurityResult (par 6.1.3)
433
        result = sock.recv(4)
434
        self.assertEquals(list(result), ['\x00', '\x00', '\x00', '\x00'])
435
        sock.close()
436

    
437
    def test_004_server_has_ipv4(self):
438
        """Test active server has a valid IPv4 address"""
439
        server = self.client.get_server_details(self.serverid)
440
        ipv4 = self._get_ipv4(server)
441
        self.assertEquals(IP(ipv4).version(), 4)
442

    
443
    def test_005_server_has_ipv6(self):
444
        """Test active server has a valid IPv6 address"""
445
        server = self.client.get_server_details(self.serverid)
446
        ipv6 = self._get_ipv6(server)
447
        self.assertEquals(IP(ipv6).version(), 6)
448

    
449
    def test_006_server_responds_to_ping_IPv4(self):
450
        """Test server responds to ping on IPv4 address"""
451
        server = self.client.get_server_details(self.serverid)
452
        ip = self._get_ipv4(server)
453
        self._try_until_timeout_expires(self.action_timeout,
454
                                        self.action_timeout,
455
                                        "PING IPv4 to %s" % ip,
456
                                        self._ping_once,
457
                                        False, ip)
458

    
459
    def test_007_server_responds_to_ping_IPv6(self):
460
        """Test server responds to ping on IPv6 address"""
461
        server = self.client.get_server_details(self.serverid)
462
        ip = self._get_ipv6(server)
463
        self._try_until_timeout_expires(self.action_timeout,
464
                                        self.action_timeout,
465
                                        "PING IPv6 to %s" % ip,
466
                                        self._ping_once,
467
                                        True, ip)
468

    
469
    def test_008_submit_shutdown_request(self):
470
        """Test submit request to shutdown server"""
471
        self.client.shutdown_server(self.serverid)
472

    
473
    def test_009_server_becomes_stopped(self):
474
        """Test server becomes STOPPED"""
475
        self._insist_on_status_transition("ACTIVE", "STOPPED",
476
                                         self.action_timeout,
477
                                         self.action_timeout)
478

    
479
    def test_010_submit_start_request(self):
480
        """Test submit start server request"""
481
        self.client.start_server(self.serverid)
482

    
483
    def test_011_server_becomes_active(self):
484
        """Test server becomes ACTIVE again"""
485
        self._insist_on_status_transition("STOPPED", "ACTIVE",
486
                                         self.action_timeout,
487
                                         self.action_timeout)
488

    
489
    def test_011a_server_responds_to_ping_IPv4(self):
490
        """Test server OS is actually up and running again"""
491
        self.test_006_server_responds_to_ping_IPv4()
492

    
493
    def test_012_ssh_to_server_IPv4(self):
494
        """Test SSH to server public IPv4 works, verify hostname"""
495
        self._skipIf(self.is_windows, "only valid for Linux servers")
496
        server = self.client.get_server_details(self.serverid)
497
        self._insist_on_ssh_hostname(self._get_ipv4(server),
498
                                     self.username, self.passwd)
499

    
500
    def test_013_ssh_to_server_IPv6(self):
501
        """Test SSH to server public IPv6 works, verify hostname"""
502
        self._skipIf(self.is_windows, "only valid for Linux servers")
503
        server = self.client.get_server_details(self.serverid)
504
        self._insist_on_ssh_hostname(self._get_ipv6(server),
505
                                     self.username, self.passwd)
506

    
507
    def test_014_rdp_to_server_IPv4(self):
508
        "Test RDP connection to server public IPv4 works"""
509
        self._skipIf(not self.is_windows, "only valid for Windows servers")
510
        server = self.client.get_server_details(self.serverid)
511
        ipv4 = self._get_ipv4(server)
512
        sock = _insist_on_tcp_connection(socket.AF_INET, ipv4, 3389)
513

    
514
        # No actual RDP processing done. We assume the RDP server is there
515
        # if the connection to the RDP port is successful.
516
        sock.close()
517

    
518
    def test_015_rdp_to_server_IPv6(self):
519
        "Test RDP connection to server public IPv6 works"""
520
        self._skipIf(not self.is_windows, "only valid for Windows servers")
521
        server = self.client.get_server_details(self.serverid)
522
        ipv6 = self._get_ipv6(server)
523
        sock = _get_tcp_connection(socket.AF_INET6, ipv6, 3389)
524

    
525
        # No actual RDP processing done. We assume the RDP server is there
526
        # if the connection to the RDP port is successful.
527
        sock.close()
528

    
529
    def test_016_personality_is_enforced(self):
530
        """Test file injection for personality enforcement"""
531
        self._skipIf(self.is_windows, "only implemented for Linux servers")
532
        self.assertTrue(False, "test not implemented, will fail")
533

    
534
    def test_017_submit_delete_request(self):
535
        """Test submit request to delete server"""
536
        self.client.delete_server(self.serverid)
537

    
538
    def test_018_server_becomes_deleted(self):
539
        """Test server becomes DELETED"""
540
        self._insist_on_status_transition("ACTIVE", "DELETED",
541
                                         self.action_timeout,
542
                                         self.action_timeout)
543

    
544
    def test_019_server_no_longer_in_server_list(self):
545
        """Test server is no longer in server list"""
546
        servers = self.client.list_servers()
547
        self.assertNotIn(self.serverid, [s["id"] for s in servers])
548

    
549

    
550
class TestRunnerProcess(Process):
551
    """A distinct process used to execute part of the tests in parallel"""
552
    def __init__(self, **kw):
553
        Process.__init__(self, **kw)
554
        kwargs = kw["kwargs"]
555
        self.testq = kwargs["testq"]
556
        self.runner = kwargs["runner"]
557

    
558
    def run(self):
559
        # Make sure this test runner process dies with the parent
560
        # and is not left behind.
561
        #
562
        # WARNING: This uses the prctl(2) call and is
563
        # Linux-specific.
564
        prctl.set_pdeathsig(signal.SIGHUP)
565

    
566
        while True:
567
            log.debug("I am process %d, GETting from queue is %s",
568
                     os.getpid(), self.testq)
569
            msg = self.testq.get()
570
            log.debug("Dequeued msg: %s", msg)
571

    
572
            if msg == "TEST_RUNNER_TERMINATE":
573
                raise SystemExit
574
            elif issubclass(msg, unittest.TestCase):
575
                # Assemble a TestSuite, and run it
576
                suite = unittest.TestLoader().loadTestsFromTestCase(msg)
577
                self.runner.run(suite)
578
            else:
579
                raise Exception("Cannot handle msg: %s" % msg)
580

    
581

    
582
def _run_cases_in_parallel(cases, fanout=1, runner=None):
583
    """Run instances of TestCase in parallel, in a number of distinct processes
584

585
    The cases iterable specifies the TestCases to be executed in parallel,
586
    by test runners running in distinct processes.
587

588
    The fanout parameter specifies the number of processes to spawn,
589
    and defaults to 1.
590

591
    The runner argument specifies the test runner class to use inside each
592
    runner process.
593

594
    """
595
    if runner is None:
596
        runner = unittest.TextTestRunner()
597

    
598
    # testq: The master process enqueues TestCase objects into this queue,
599
    #        test runner processes pick them up for execution, in parallel.
600
    testq = Queue()
601
    runners = []
602
    for i in xrange(0, fanout):
603
        kwargs = dict(testq=testq, runner=runner)
604
        runners.append(TestRunnerProcess(kwargs=kwargs))
605

    
606
    log.info("Spawning %d test runner processes", len(runners))
607
    for p in runners:
608
        p.start()
609
    log.debug("Spawned %d test runners, PIDs are %s",
610
              len(runners), [p.pid for p in runners])
611

    
612
    # Enqueue test cases
613
    map(testq.put, cases)
614
    map(testq.put, ["TEST_RUNNER_TERMINATE"] * len(runners))
615

    
616
    log.debug("Joining %d processes", len(runners))
617
    for p in runners:
618
        p.join()
619
    log.debug("Done joining %d processes", len(runners))
620

    
621

    
622
def _spawn_server_test_case(**kwargs):
623
    """Construct a new unit test case class from SpawnServerTestCase"""
624

    
625
    name = "SpawnServerTestCase_%d" % kwargs["imageid"]
626
    cls = type(name, (SpawnServerTestCase,), kwargs)
627

    
628
    # Patch extra parameters into test names by manipulating method docstrings
629
    for (mname, m) in \
630
        inspect.getmembers(cls, lambda x: inspect.ismethod(x)):
631
            if hasattr(m, __doc__):
632
                m.__func__.__doc__ = "[%s] %s" % (imagename, m.__doc__)
633

    
634
    # Make sure the class can be pickled, by listing it among
635
    # the attributes of __main__. A PicklingError is raised otherwise.
636
    setattr(__main__, name, cls)
637
    return cls
638

    
639

    
640
def cleanup_servers(delete_stale=False):
641
    c = Client(API, TOKEN)
642
    servers = c.list_servers()
643
    stale = [s for s in servers if s["name"].startswith(SNF_TEST_PREFIX)]
644

    
645
    if len(stale) == 0:
646
        return
647

    
648
    print >> sys.stderr, "Found these stale servers from previous runs:"
649
    print "    " + \
650
          "\n    ".join(["%d: %s" % (s["id"], s["name"]) for s in stale])
651

    
652
    if delete_stale:
653
        print >> sys.stderr, "Deleting %d stale servers:" % len(stale)
654
        for server in stale:
655
            c.delete_server(server["id"])
656
        print >> sys.stderr, "    ...done"
657
    else:
658
        print >> sys.stderr, "Use --delete-stale to delete them."
659

    
660

    
661
def parse_arguments(args):
662
    from optparse import OptionParser
663

    
664
    kw = {}
665
    kw["usage"] = "%prog [options]"
666
    kw["description"] = \
667
        "%prog runs a number of test scenarios on a " \
668
        "Synnefo deployment."
669

    
670
    parser = OptionParser(**kw)
671
    parser.disable_interspersed_args()
672
    parser.add_option("--api",
673
                      action="store", type="string", dest="api",
674
                      help="The API URI to use to reach the Synnefo API",
675
                      default=DEFAULT_API)
676
    parser.add_option("--token",
677
                      action="store", type="string", dest="token",
678
                      help="The token to use for authentication to the API",
679
                      default=DEFAULT_TOKEN)
680
    parser.add_option("--failfast",
681
                      action="store_true", dest="failfast",
682
                      help="Fail immediately if one of the tests fails",
683
                      default=False)
684
    parser.add_option("--action-timeout",
685
                      action="store", type="int", dest="action_timeout",
686
                      metavar="TIMEOUT",
687
                      help="Wait SECONDS seconds for a server action to " \
688
                           "complete, then the test is considered failed",
689
                      default=20)
690
    parser.add_option("--build-warning",
691
                      action="store", type="int", dest="build_warning",
692
                      metavar="TIMEOUT",
693
                      help="Warn if TIMEOUT seconds have passed and a " \
694
                           "build operation is still pending",
695
                      default=600)
696
    parser.add_option("--build-fail",
697
                      action="store", type="int", dest="build_fail",
698
                      metavar="BUILD_TIMEOUT",
699
                      help="Fail the test if TIMEOUT seconds have passed " \
700
                           "and a build operation is still incomplete",
701
                      default=900)
702
    parser.add_option("--query-interval",
703
                      action="store", type="int", dest="query_interval",
704
                      metavar="INTERVAL",
705
                      help="Query server status when requests are pending " \
706
                           "every INTERVAL seconds",
707
                      default=3)
708
    parser.add_option("--fanout",
709
                      action="store", type="int", dest="fanout",
710
                      metavar="COUNT",
711
                      help="Spawn up to COUNT child processes to execute " \
712
                           "in parallel, essentially have up to COUNT " \
713
                           "server build requests outstanding",
714
                      default=1)
715
    parser.add_option("--force-flavor",
716
                      action="store", type="int", dest="force_flavorid",
717
                      metavar="FLAVOR ID",
718
                      help="Force all server creations to use the specified "\
719
                           "FLAVOR ID instead of a randomly chosen one, " \
720
                           "useful if disk space is scarce",
721
                      default=None)    # FIXME
722
    parser.add_option("--show-stale",
723
                      action="store_true", dest="show_stale",
724
                      help="Show stale servers from previous runs, whose "\
725
                           "name starts with `%s'" % SNF_TEST_PREFIX,
726
                      default=False)
727
    parser.add_option("--delete-stale",
728
                      action="store_true", dest="delete_stale",
729
                      help="Delete stale servers from previous runs, whose "\
730
                           "name starts with `%s'" % SNF_TEST_PREFIX,
731
                      default=False)
732

    
733
    # FIXME: Change the default for build-fanout to 10
734
    # FIXME: Allow the user to specify a specific set of Images to test
735

    
736
    (opts, args) = parser.parse_args(args)
737

    
738
    # Verify arguments
739
    if opts.delete_stale:
740
        opts.show_stale = True
741

    
742
    return (opts, args)
743

    
744

    
745
def main():
746
    """Assemble test cases into a test suite, and run it
747

748
    IMPORTANT: Tests have dependencies and have to be run in the specified
749
    order inside a single test case. They communicate through attributes of the
750
    corresponding TestCase class (shared fixtures). Distinct subclasses of
751
    TestCase MAY SHARE NO DATA, since they are run in parallel, in distinct
752
    test runner processes.
753

754
    """
755
    (opts, args) = parse_arguments(sys.argv[1:])
756

    
757
    global API, TOKEN
758
    API = opts.api
759
    TOKEN = opts.token
760

    
761
    # Cleanup stale servers from previous runs
762
    if opts.show_stale:
763
        cleanup_servers(delete_stale=opts.delete_stale)
764
        return 0
765

    
766
    # Initialize a kamaki instance, get flavors, images
767
    c = Client(API, TOKEN)
768
    DIMAGES = c.list_images(detail=True)
769
    DFLAVORS = c.list_flavors(detail=True)
770

    
771
    # FIXME: logging, log, LOG PID, TEST_RUN_ID, arguments
772
    # FIXME: Network testing? Create, destroy, connect, ping, disconnect VMs?
773
    # Run them: FIXME: In parallel, FAILEARLY, catchbreak?
774
    #unittest.main(verbosity=2, catchbreak=True)
775

    
776
    runner = unittest.TextTestRunner(verbosity=2, failfast=opts.failfast)
777
    # The following cases run sequentially
778
    seq_cases = [UnauthorizedTestCase, FlavorsTestCase, ImagesTestCase]
779
    _run_cases_in_parallel(seq_cases, fanout=3, runner=runner)
780

    
781
    # The following cases run in parallel
782
    par_cases = []
783

    
784
    for image in DIMAGES:
785
        imageid = image["id"]
786
        imagename = image["name"]
787
        if opts.force_flavorid:
788
            flavorid = opts.force_flavorid
789
        else:
790
            flavorid = choice([f["id"] for f in DFLAVORS if f["disk"] >= 20])
791
        personality = None   # FIXME
792
        servername = "%s%s for %s" % (SNF_TEST_PREFIX, TEST_RUN_ID, imagename)
793
        is_windows = imagename.lower().find("windows") >= 0
794
        case = _spawn_server_test_case(imageid=imageid, flavorid=flavorid,
795
                                       imagename=imagename,
796
                                       personality=personality,
797
                                       servername=servername,
798
                                       is_windows=is_windows,
799
                                       action_timeout=opts.action_timeout,
800
                                       build_warning=opts.build_warning,
801
                                       build_fail=opts.build_fail,
802
                                       query_interval=opts.query_interval)
803
        par_cases.append(case)
804

    
805
    print "%s" % FlavorsTestCase
806
    print "dict", __main__.__dict__
807
    _run_cases_in_parallel(par_cases, fanout=opts.fanout, runner=runner)
808

    
809
if __name__ == "__main__":
810
    sys.exit(main())