Statistics
| Branch: | Tag: | Revision:

root / ci / utils.py @ 6a99aca3

History | View | Annotate | Download (39.2 kB)

1
#!/usr/bin/env python
2

    
3
"""
4
Synnefo ci utils module
5
"""
6

    
7
import os
8
import re
9
import sys
10
import time
11
import logging
12
import fabric.api as fabric
13
import subprocess
14
import tempfile
15
from ConfigParser import ConfigParser, DuplicateSectionError
16

    
17
from kamaki.cli import config as kamaki_config
18
from kamaki.clients.astakos import AstakosClient
19
from kamaki.clients.cyclades import CycladesClient, CycladesNetworkClient
20
from kamaki.clients.image import ImageClient
21
from kamaki.clients.compute import ComputeClient
22
import filelocker
23

    
24
DEFAULT_CONFIG_FILE = "ci_wheezy.conf"
25
# Is our terminal a colorful one?
26
USE_COLORS = True
27
# UUID of owner of system images
28
DEFAULT_SYSTEM_IMAGES_UUID = [
29
    "25ecced9-bf53-4145-91ee-cf47377e9fb2",  # production (okeanos.grnet.gr)
30
    "04cbe33f-29b7-4ef1-94fb-015929e5fc06",  # testing (okeanos.io)
31
]
32

    
33

    
34
def _run(cmd, verbose):
35
    """Run fabric with verbose level"""
36
    if verbose:
37
        args = ('running',)
38
    else:
39
        args = ('running', 'stdout',)
40
    with fabric.hide(*args):  # Used * or ** magic. pylint: disable-msg=W0142
41
        return fabric.run(cmd)
42

    
43

    
44
def _put(local, remote):
45
    """Run fabric put command without output"""
46
    with fabric.quiet():
47
        fabric.put(local, remote)
48

    
49

    
50
def _red(msg):
51
    """Red color"""
52
    ret = "\x1b[31m" + str(msg) + "\x1b[0m" if USE_COLORS else str(msg)
53
    return ret
54

    
55

    
56
def _yellow(msg):
57
    """Yellow color"""
58
    ret = "\x1b[33m" + str(msg) + "\x1b[0m" if USE_COLORS else str(msg)
59
    return ret
60

    
61

    
62
def _green(msg):
63
    """Green color"""
64
    ret = "\x1b[32m" + str(msg) + "\x1b[0m" if USE_COLORS else str(msg)
65
    return ret
66

    
67

    
68
def _check_fabric(fun):
69
    """Check if fabric env has been set"""
70
    def wrapper(self, *args, **kwargs):
71
        """wrapper function"""
72
        if not self.fabric_installed:
73
            self.setup_fabric()
74
            self.fabric_installed = True
75
        return fun(self, *args, **kwargs)
76
    return wrapper
77

    
78

    
79
def _check_kamaki(fun):
80
    """Check if kamaki has been initialized"""
81
    def wrapper(self, *args, **kwargs):
82
        """wrapper function"""
83
        if not self.kamaki_installed:
84
            self.setup_kamaki()
85
            self.kamaki_installed = True
86
        return fun(self, *args, **kwargs)
87
    return wrapper
88

    
89

    
90
class _MyFormatter(logging.Formatter):
91
    """Logging Formatter"""
92
    def format(self, record):
93
        format_orig = self._fmt
94
        if record.levelno == logging.DEBUG:
95
            self._fmt = "  %(message)s"
96
        elif record.levelno == logging.INFO:
97
            self._fmt = "%(message)s"
98
        elif record.levelno == logging.WARNING:
99
            self._fmt = _yellow("[W] %(message)s")
100
        elif record.levelno == logging.ERROR:
101
            self._fmt = _red("[E] %(message)s")
102
        result = logging.Formatter.format(self, record)
103
        self._fmt = format_orig
104
        return result
105

    
106

    
107
# Too few public methods. pylint: disable-msg=R0903
108
class _InfoFilter(logging.Filter):
109
    """Logging Filter that allows DEBUG and INFO messages only"""
110
    def filter(self, rec):
111
        """The filter"""
112
        return rec.levelno in (logging.DEBUG, logging.INFO)
113

    
114

    
115
# Too many instance attributes. pylint: disable-msg=R0902
116
class SynnefoCI(object):
117
    """SynnefoCI python class"""
118

    
119
    def __init__(self, config_file=None, build_id=None, cloud=None):
120
        """ Initialize SynnefoCI python class
121

122
        Setup logger, local_dir, config and kamaki
123
        """
124
        # Setup logger
125
        self.logger = logging.getLogger('synnefo-ci')
126
        self.logger.setLevel(logging.DEBUG)
127

    
128
        handler1 = logging.StreamHandler(sys.stdout)
129
        handler1.setLevel(logging.DEBUG)
130
        handler1.addFilter(_InfoFilter())
131
        handler1.setFormatter(_MyFormatter())
132
        handler2 = logging.StreamHandler(sys.stderr)
133
        handler2.setLevel(logging.WARNING)
134
        handler2.setFormatter(_MyFormatter())
135

    
136
        self.logger.addHandler(handler1)
137
        self.logger.addHandler(handler2)
138

    
139
        # Get our local dir
140
        self.ci_dir = os.path.dirname(os.path.abspath(__file__))
141
        self.repo_dir = os.path.dirname(self.ci_dir)
142

    
143
        # Read config file
144
        if config_file is None:
145
            config_file = os.path.join(self.ci_dir, DEFAULT_CONFIG_FILE)
146
        config_file = os.path.abspath(config_file)
147
        self.config = ConfigParser()
148
        self.config.optionxform = str
149
        self.config.read(config_file)
150

    
151
        # Read temporary_config file
152
        self.temp_config_file = \
153
            os.path.expanduser(self.config.get('Global', 'temporary_config'))
154
        self.temp_config = ConfigParser()
155
        self.temp_config.optionxform = str
156
        self.temp_config.read(self.temp_config_file)
157
        self.build_id = build_id
158
        if build_id is not None:
159
            self.logger.info("Will use \"%s\" as build id" %
160
                             _green(self.build_id))
161

    
162
        # Set kamaki cloud
163
        if cloud is not None:
164
            self.kamaki_cloud = cloud
165
        elif self.config.has_option("Deployment", "kamaki_cloud"):
166
            kamaki_cloud = self.config.get("Deployment", "kamaki_cloud")
167
            if kamaki_cloud == "":
168
                self.kamaki_cloud = None
169
        else:
170
            self.kamaki_cloud = None
171

    
172
        # Initialize variables
173
        self.fabric_installed = False
174
        self.kamaki_installed = False
175
        self.cyclades_client = None
176
        self.network_client = None
177
        self.compute_client = None
178
        self.image_client = None
179
        self.astakos_client = None
180

    
181
    def setup_kamaki(self):
182
        """Initialize kamaki
183

184
        Setup cyclades_client, image_client and compute_client
185
        """
186

    
187
        config = kamaki_config.Config()
188
        if self.kamaki_cloud is None:
189
            try:
190
                self.kamaki_cloud = config.get("global", "default_cloud")
191
            except AttributeError:
192
                # Compatibility with kamaki version <=0.10
193
                self.kamaki_cloud = config.get("global", "default_cloud")
194

    
195
        self.logger.info("Setup kamaki client, using cloud '%s'.." %
196
                         self.kamaki_cloud)
197
        auth_url = config.get_cloud(self.kamaki_cloud, "url")
198
        self.logger.debug("Authentication URL is %s" % _green(auth_url))
199
        token = config.get_cloud(self.kamaki_cloud, "token")
200
        #self.logger.debug("Token is %s" % _green(token))
201

    
202
        self.astakos_client = AstakosClient(auth_url, token)
203

    
204
        cyclades_url = \
205
            self.astakos_client.get_service_endpoints('compute')['publicURL']
206
        self.logger.debug("Cyclades API url is %s" % _green(cyclades_url))
207
        self.cyclades_client = CycladesClient(cyclades_url, token)
208
        self.cyclades_client.CONNECTION_RETRY_LIMIT = 2
209

    
210
        network_url = \
211
            self.astakos_client.get_service_endpoints('network')['publicURL']
212
        self.logger.debug("Network API url is %s" % _green(network_url))
213
        self.network_client = CycladesNetworkClient(network_url, token)
214
        self.network_client.CONNECTION_RETRY_LIMIT = 2
215

    
216
        image_url = \
217
            self.astakos_client.get_service_endpoints('image')['publicURL']
218
        self.logger.debug("Images API url is %s" % _green(image_url))
219
        self.image_client = ImageClient(cyclades_url, token)
220
        self.image_client.CONNECTION_RETRY_LIMIT = 2
221

    
222
        compute_url = \
223
            self.astakos_client.get_service_endpoints('compute')['publicURL']
224
        self.logger.debug("Compute API url is %s" % _green(compute_url))
225
        self.compute_client = ComputeClient(compute_url, token)
226
        self.compute_client.CONNECTION_RETRY_LIMIT = 2
227

    
228
    def _wait_transition(self, server_id, current_status, new_status):
229
        """Wait for server to go from current_status to new_status"""
230
        self.logger.debug("Waiting for server to become %s" % new_status)
231
        timeout = self.config.getint('Global', 'build_timeout')
232
        sleep_time = 5
233
        while True:
234
            server = self.cyclades_client.get_server_details(server_id)
235
            if server['status'] == new_status:
236
                return server
237
            elif timeout < 0:
238
                self.logger.error(
239
                    "Waiting for server to become %s timed out" % new_status)
240
                self.destroy_server(False)
241
                sys.exit(1)
242
            elif server['status'] == current_status:
243
                # Sleep for #n secs and continue
244
                timeout = timeout - sleep_time
245
                time.sleep(sleep_time)
246
            else:
247
                self.logger.error(
248
                    "Server failed with status %s" % server['status'])
249
                self.destroy_server(False)
250
                sys.exit(1)
251

    
252
    @_check_kamaki
253
    def destroy_server(self, wait=True):
254
        """Destroy slave server"""
255
        server_id = int(self.read_temp_config('server_id'))
256
        fips = [f for f in self.network_client.list_floatingips()
257
                if str(f['instance_id']) == str(server_id)]
258
        self.logger.info("Destoying server with id %s " % server_id)
259
        self.cyclades_client.delete_server(server_id)
260
        if wait:
261
            self._wait_transition(server_id, "ACTIVE", "DELETED")
262
        for fip in fips:
263
            self.logger.info("Destroying floating ip %s",
264
                             fip['floating_ip_address'])
265
            self.network_client.delete_floatingip(fip['id'])
266

    
267
    def _create_floating_ip(self):
268
        """Create a new floating ip"""
269
        networks = self.network_client.list_networks(detail=True)
270
        pub_net = [n for n in networks
271
                   if n['SNF:floating_ip_pool'] and n['public']]
272
        pub_net = pub_net[0]
273
        fip = self.network_client.create_floatingip(pub_net['id'])
274
        self.logger.debug("Floating IP %s with id %s created",
275
                          fip['floating_ip_address'], fip['id'])
276
        return fip
277

    
278
    def _create_port(self, floating_ip):
279
        """Create a new port for our floating IP"""
280
        net_id = floating_ip['floating_network_id']
281
        self.logger.debug("Creating a new port to network with id %s", net_id)
282
        fixed_ips = [{'ip_address': floating_ip['floating_ip_address']}]
283
        port = self.network_client.create_port(
284
            net_id, device_id=None, fixed_ips=fixed_ips)
285
        return port
286

    
287
    @_check_kamaki
288
    def create_server(self, image=None, flavor=None, ssh_keys=None,
289
                      server_name=None):
290
        """Create slave server"""
291
        self.logger.info("Create a new server..")
292

    
293
        # Find a build_id to use
294
        self._create_new_build_id()
295

    
296
        # Find an image to use
297
        image_id = self._find_image(image)
298
        # Find a flavor to use
299
        flavor_id = self._find_flavor(flavor)
300

    
301
        # Create Server
302
        networks = []
303
        if self.config.get("Deployment", "allocate_floating_ip") == "True":
304
            fip = self._create_floating_ip()
305
            port = self._create_port(fip)
306
            networks.append({'port': port['id']})
307
        private_networks = self.config.get('Deployment', 'private_networks')
308
        if private_networks:
309
            private_networks = private_networks.split(",")
310
            networks.extend([{"uuid": uuid.strip()}
311
                             for uuid in private_networks])
312
        if server_name is None:
313
            server_name = self.config.get("Deployment", "server_name")
314
            server_name = "%s(BID: %s)" % (server_name, self.build_id)
315
        server = self.cyclades_client.create_server(
316
            server_name, flavor_id, image_id, networks=networks)
317
        server_id = server['id']
318
        self.write_temp_config('server_id', server_id)
319
        self.logger.debug("Server got id %s" % _green(server_id))
320
        server_user = server['metadata']['users']
321
        self.write_temp_config('server_user', server_user)
322
        self.logger.debug("Server's admin user is %s" % _green(server_user))
323
        server_passwd = server['adminPass']
324
        self.write_temp_config('server_passwd', server_passwd)
325

    
326
        server = self._wait_transition(server_id, "BUILD", "ACTIVE")
327
        self._get_server_ip_and_port(server)
328
        self._copy_ssh_keys(ssh_keys)
329

    
330
        # Setup Firewall
331
        self.setup_fabric()
332
        self.logger.info("Setup firewall")
333
        accept_ssh_from = self.config.get('Global', 'accept_ssh_from')
334
        if accept_ssh_from != "":
335
            self.logger.debug("Block ssh except from %s" % accept_ssh_from)
336
            cmd = """
337
            local_ip=$(/sbin/ifconfig eth0 | grep 'inet addr:' | \
338
                cut -d':' -f2 | cut -d' ' -f1)
339
            iptables -A INPUT -s localhost -j ACCEPT
340
            iptables -A INPUT -s $local_ip -j ACCEPT
341
            iptables -A INPUT -s {0} -p tcp --dport 22 -j ACCEPT
342
            iptables -A INPUT -p tcp --dport 22 -j DROP
343
            """.format(accept_ssh_from)
344
            _run(cmd, False)
345

    
346
        # Setup apt, download packages
347
        self.logger.debug("Setup apt. Install x2goserver and firefox")
348
        cmd = """
349
        echo 'APT::Install-Suggests "false";' >> /etc/apt/apt.conf
350
        echo 'precedence ::ffff:0:0/96  100' >> /etc/gai.conf
351
        apt-get update
352
        apt-get install curl --yes --force-yes
353
        echo -e "\n\n{0}" >> /etc/apt/sources.list
354
        # Synnefo repo's key
355
        curl https://dev.grnet.gr/files/apt-grnetdev.pub | apt-key add -
356

357
        # X2GO Key
358
        apt-key adv --recv-keys --keyserver keys.gnupg.net E1F958385BFE2B6E
359
        apt-get install x2go-keyring --yes --force-yes
360
        apt-get update
361
        apt-get install x2goserver x2goserver-xsession \
362
                iceweasel --yes --force-yes
363

364
        # xterm published application
365
        echo '[Desktop Entry]' > /usr/share/applications/xterm.desktop
366
        echo 'Name=XTerm' >> /usr/share/applications/xterm.desktop
367
        echo 'Comment=standard terminal emulator for the X window system' >> \
368
            /usr/share/applications/xterm.desktop
369
        echo 'Exec=xterm' >> /usr/share/applications/xterm.desktop
370
        echo 'Terminal=false' >> /usr/share/applications/xterm.desktop
371
        echo 'Type=Application' >> /usr/share/applications/xterm.desktop
372
        echo 'Encoding=UTF-8' >> /usr/share/applications/xterm.desktop
373
        echo 'Icon=xterm-color_48x48' >> /usr/share/applications/xterm.desktop
374
        echo 'Categories=System;TerminalEmulator;' >> \
375
                /usr/share/applications/xterm.desktop
376
        """.format(self.config.get('Global', 'apt_repo'))
377
        _run(cmd, False)
378

    
379
    def _find_flavor(self, flavor=None):
380
        """Find a suitable flavor to use
381

382
        Search by name (reg expression) or by id
383
        """
384
        # Get a list of flavors from config file
385
        flavors = self.config.get('Deployment', 'flavors').split(",")
386
        if flavor is not None:
387
            # If we have a flavor_name to use, add it to our list
388
            flavors.insert(0, flavor)
389

    
390
        list_flavors = self.compute_client.list_flavors()
391
        for flv in flavors:
392
            flv_type, flv_value = parse_typed_option(option="flavor",
393
                                                     value=flv)
394
            if flv_type == "name":
395
                # Filter flavors by name
396
                self.logger.debug(
397
                    "Trying to find a flavor with name \"%s\"" % flv_value)
398
                list_flvs = \
399
                    [f for f in list_flavors
400
                     if re.search(flv_value, f['name'], flags=re.I)
401
                     is not None]
402
            elif flv_type == "id":
403
                # Filter flavors by id
404
                self.logger.debug(
405
                    "Trying to find a flavor with id \"%s\"" % flv_value)
406
                list_flvs = \
407
                    [f for f in list_flavors
408
                     if str(f['id']) == flv_value]
409
            else:
410
                self.logger.error("Unrecognized flavor type %s" % flv_type)
411

    
412
            # Check if we found one
413
            if list_flvs:
414
                self.logger.debug("Will use \"%s\" with id \"%s\""
415
                                  % (_green(list_flvs[0]['name']),
416
                                     _green(list_flvs[0]['id'])))
417
                return list_flvs[0]['id']
418

    
419
        self.logger.error("No matching flavor found.. aborting")
420
        sys.exit(1)
421

    
422
    def _find_image(self, image=None):
423
        """Find a suitable image to use
424

425
        In case of search by name, the image has to belong to one
426
        of the `DEFAULT_SYSTEM_IMAGES_UUID' users.
427
        In case of search by id it only has to exist.
428
        """
429
        # Get a list of images from config file
430
        images = self.config.get('Deployment', 'images').split(",")
431
        if image is not None:
432
            # If we have an image from command line, add it to our list
433
            images.insert(0, image)
434

    
435
        auth = self.astakos_client.authenticate()
436
        user_uuid = auth["access"]["token"]["tenant"]["id"]
437
        list_images = self.image_client.list_public(detail=True)['images']
438
        for img in images:
439
            img_type, img_value = parse_typed_option(option="image", value=img)
440
            if img_type == "name":
441
                # Filter images by name
442
                self.logger.debug(
443
                    "Trying to find an image with name \"%s\"" % img_value)
444
                accepted_uuids = DEFAULT_SYSTEM_IMAGES_UUID + [user_uuid]
445
                list_imgs = \
446
                    [i for i in list_images if i['user_id'] in accepted_uuids
447
                     and
448
                     re.search(img_value, i['name'], flags=re.I) is not None]
449
            elif img_type == "id":
450
                # Filter images by id
451
                self.logger.debug(
452
                    "Trying to find an image with id \"%s\"" % img_value)
453
                list_imgs = \
454
                    [i for i in list_images
455
                     if i['id'].lower() == img_value.lower()]
456
            else:
457
                self.logger.error("Unrecognized image type %s" % img_type)
458
                sys.exit(1)
459

    
460
            # Check if we found one
461
            if list_imgs:
462
                self.logger.debug("Will use \"%s\" with id \"%s\""
463
                                  % (_green(list_imgs[0]['name']),
464
                                     _green(list_imgs[0]['id'])))
465
                return list_imgs[0]['id']
466

    
467
        # We didn't found one
468
        self.logger.error("No matching image found.. aborting")
469
        sys.exit(1)
470

    
471
    def _get_server_ip_and_port(self, server):
472
        """Compute server's IPv4 and ssh port number"""
473
        self.logger.info("Get server connection details..")
474
        server_ip = server['attachments'][0]['ipv4']
475
        if (".okeanos.io" in self.cyclades_client.base_url or
476
           ".demo.synnefo.org" in self.cyclades_client.base_url):
477
            tmp1 = int(server_ip.split(".")[2])
478
            tmp2 = int(server_ip.split(".")[3])
479
            server_ip = "gate.okeanos.io"
480
            server_port = 10000 + tmp1 * 256 + tmp2
481
        else:
482
            server_port = 22
483
        self.write_temp_config('server_ip', server_ip)
484
        self.logger.debug("Server's IPv4 is %s" % _green(server_ip))
485
        self.write_temp_config('server_port', server_port)
486
        self.logger.debug("Server's ssh port is %s" % _green(server_port))
487
        ssh_command = "ssh -p %s %s@%s" \
488
            % (server_port, server['metadata']['users'], server_ip)
489
        self.logger.debug("Access server using \"%s\"" %
490
                          (_green(ssh_command)))
491

    
492
    @_check_fabric
493
    def _copy_ssh_keys(self, ssh_keys):
494
        """Upload/Install ssh keys to server"""
495
        self.logger.debug("Check for authentication keys to use")
496
        if ssh_keys is None:
497
            ssh_keys = self.config.get("Deployment", "ssh_keys")
498

    
499
        if ssh_keys != "":
500
            ssh_keys = os.path.expanduser(ssh_keys)
501
            self.logger.debug("Will use \"%s\" authentication keys file" %
502
                              _green(ssh_keys))
503
            keyfile = '/tmp/%s.pub' % fabric.env.user
504
            _run('mkdir -p ~/.ssh && chmod 700 ~/.ssh', False)
505
            if ssh_keys.startswith("http://") or \
506
                    ssh_keys.startswith("https://") or \
507
                    ssh_keys.startswith("ftp://"):
508
                cmd = """
509
                apt-get update
510
                apt-get install wget --yes --force-yes
511
                wget {0} -O {1} --no-check-certificate
512
                """.format(ssh_keys, keyfile)
513
                _run(cmd, False)
514
            elif os.path.exists(ssh_keys):
515
                _put(ssh_keys, keyfile)
516
            else:
517
                self.logger.debug("No ssh keys found")
518
                return
519
            _run('cat %s >> ~/.ssh/authorized_keys' % keyfile, False)
520
            _run('rm %s' % keyfile, False)
521
            self.logger.debug("Uploaded ssh authorized keys")
522
        else:
523
            self.logger.debug("No ssh keys found")
524

    
525
    def _create_new_build_id(self):
526
        """Find a uniq build_id to use"""
527
        with filelocker.lock("%s.lock" % self.temp_config_file,
528
                             filelocker.LOCK_EX):
529
            # Read temp_config again to get any new entries
530
            self.temp_config.read(self.temp_config_file)
531

    
532
            # Find a uniq build_id to use
533
            if self.build_id is None:
534
                ids = self.temp_config.sections()
535
                if ids:
536
                    max_id = int(max(self.temp_config.sections(), key=int))
537
                    self.build_id = max_id + 1
538
                else:
539
                    self.build_id = 1
540
            self.logger.debug("Will use \"%s\" as build id"
541
                              % _green(self.build_id))
542

    
543
            # Create a new section
544
            try:
545
                self.temp_config.add_section(str(self.build_id))
546
            except DuplicateSectionError:
547
                msg = ("Build id \"%s\" already in use. " +
548
                       "Please use a uniq one or cleanup \"%s\" file.\n") \
549
                    % (self.build_id, self.temp_config_file)
550
                self.logger.error(msg)
551
                sys.exit(1)
552
            creation_time = \
553
                time.strftime("%a, %d %b %Y %X", time.localtime())
554
            self.temp_config.set(str(self.build_id),
555
                                 "created", str(creation_time))
556

    
557
            # Write changes back to temp config file
558
            with open(self.temp_config_file, 'wb') as tcf:
559
                self.temp_config.write(tcf)
560

    
561
    def write_temp_config(self, option, value):
562
        """Write changes back to config file"""
563
        # Acquire the lock to write to temp_config_file
564
        with filelocker.lock("%s.lock" % self.temp_config_file,
565
                             filelocker.LOCK_EX):
566

    
567
            # Read temp_config again to get any new entries
568
            self.temp_config.read(self.temp_config_file)
569

    
570
            self.temp_config.set(str(self.build_id), option, str(value))
571
            curr_time = time.strftime("%a, %d %b %Y %X", time.localtime())
572
            self.temp_config.set(str(self.build_id), "modified", curr_time)
573

    
574
            # Write changes back to temp config file
575
            with open(self.temp_config_file, 'wb') as tcf:
576
                self.temp_config.write(tcf)
577

    
578
    def read_temp_config(self, option):
579
        """Read from temporary_config file"""
580
        # If build_id is None use the latest one
581
        if self.build_id is None:
582
            ids = self.temp_config.sections()
583
            if ids:
584
                self.build_id = int(ids[-1])
585
            else:
586
                self.logger.error("No sections in temporary config file")
587
                sys.exit(1)
588
            self.logger.debug("Will use \"%s\" as build id"
589
                              % _green(self.build_id))
590
        # Read specified option
591
        return self.temp_config.get(str(self.build_id), option)
592

    
593
    def setup_fabric(self):
594
        """Setup fabric environment"""
595
        self.logger.info("Setup fabric parameters..")
596
        fabric.env.user = self.read_temp_config('server_user')
597
        fabric.env.host_string = self.read_temp_config('server_ip')
598
        fabric.env.port = int(self.read_temp_config('server_port'))
599
        fabric.env.password = self.read_temp_config('server_passwd')
600
        fabric.env.connection_attempts = 10
601
        fabric.env.shell = "/bin/bash -c"
602
        fabric.env.disable_known_hosts = True
603
        fabric.env.output_prefix = None
604

    
605
    def _check_hash_sum(self, localfile, remotefile):
606
        """Check hash sums of two files"""
607
        self.logger.debug("Check hash sum for local file %s" % localfile)
608
        hash1 = os.popen("sha256sum %s" % localfile).read().split(' ')[0]
609
        self.logger.debug("Local file has sha256 hash %s" % hash1)
610
        self.logger.debug("Check hash sum for remote file %s" % remotefile)
611
        hash2 = _run("sha256sum %s" % remotefile, False)
612
        hash2 = hash2.split(' ')[0]
613
        self.logger.debug("Remote file has sha256 hash %s" % hash2)
614
        if hash1 != hash2:
615
            self.logger.error("Hashes differ.. aborting")
616
            sys.exit(1)
617

    
618
    @_check_fabric
619
    def clone_repo(self, local_repo=False):
620
        """Clone Synnefo repo from slave server"""
621
        self.logger.info("Configure repositories on remote server..")
622
        self.logger.debug("Install/Setup git")
623
        cmd = """
624
        apt-get install git --yes --force-yes
625
        git config --global user.name {0}
626
        git config --global user.email {1}
627
        """.format(self.config.get('Global', 'git_config_name'),
628
                   self.config.get('Global', 'git_config_mail'))
629
        _run(cmd, False)
630

    
631
        # Clone synnefo_repo
632
        synnefo_branch = self.clone_synnefo_repo(local_repo=local_repo)
633
        # Clone pithos-web-client
634
        self.clone_pithos_webclient_repo(synnefo_branch)
635

    
636
    @_check_fabric
637
    def clone_synnefo_repo(self, local_repo=False):
638
        """Clone Synnefo repo to remote server"""
639
        # Find synnefo_repo and synnefo_branch to use
640
        synnefo_repo = self.config.get('Global', 'synnefo_repo')
641
        synnefo_branch = self.config.get("Global", "synnefo_branch")
642
        if synnefo_branch == "":
643
            synnefo_branch = \
644
                subprocess.Popen(
645
                    ["git", "rev-parse", "--abbrev-ref", "HEAD"],
646
                    stdout=subprocess.PIPE).communicate()[0].strip()
647
            if synnefo_branch == "HEAD":
648
                synnefo_branch = \
649
                    subprocess.Popen(
650
                        ["git", "rev-parse", "--short", "HEAD"],
651
                        stdout=subprocess.PIPE).communicate()[0].strip()
652
        self.logger.debug("Will use branch \"%s\"" % _green(synnefo_branch))
653

    
654
        if local_repo or synnefo_repo == "":
655
            # Use local_repo
656
            self.logger.debug("Push local repo to server")
657
            # Firstly create the remote repo
658
            _run("git init synnefo", False)
659
            # Then push our local repo over ssh
660
            # We have to pass some arguments to ssh command
661
            # namely to disable host checking.
662
            (temp_ssh_file_handle, temp_ssh_file) = tempfile.mkstemp()
663
            os.close(temp_ssh_file_handle)
664
            # XXX: git push doesn't read the password
665
            cmd = """
666
            echo 'exec ssh -o "StrictHostKeyChecking no" \
667
                           -o "UserKnownHostsFile /dev/null" \
668
                           -q "$@"' > {4}
669
            chmod u+x {4}
670
            export GIT_SSH="{4}"
671
            echo "{0}" | git push --quiet --mirror ssh://{1}@{2}:{3}/~/synnefo
672
            rm -f {4}
673
            """.format(fabric.env.password,
674
                       fabric.env.user,
675
                       fabric.env.host_string,
676
                       fabric.env.port,
677
                       temp_ssh_file)
678
            os.system(cmd)
679
        else:
680
            # Clone Synnefo from remote repo
681
            self.logger.debug("Clone synnefo from %s" % synnefo_repo)
682
            self._git_clone(synnefo_repo)
683

    
684
        # Checkout the desired synnefo_branch
685
        self.logger.debug("Checkout \"%s\" branch/commit" % synnefo_branch)
686
        cmd = """
687
        cd synnefo
688
        for branch in `git branch -a | grep remotes | grep -v HEAD`; do
689
            git branch --track ${branch##*/} $branch
690
        done
691
        git checkout %s
692
        """ % (synnefo_branch)
693
        _run(cmd, False)
694

    
695
        return synnefo_branch
696

    
697
    @_check_fabric
698
    def clone_pithos_webclient_repo(self, synnefo_branch):
699
        """Clone Pithos WebClient repo to remote server"""
700
        # Find pithos_webclient_repo and pithos_webclient_branch to use
701
        pithos_webclient_repo = \
702
            self.config.get('Global', 'pithos_webclient_repo')
703
        pithos_webclient_branch = \
704
            self.config.get('Global', 'pithos_webclient_branch')
705

    
706
        # Clone pithos-webclient from remote repo
707
        self.logger.debug("Clone pithos-webclient from %s" %
708
                          pithos_webclient_repo)
709
        self._git_clone(pithos_webclient_repo)
710

    
711
        # Track all pithos-webclient branches
712
        cmd = """
713
        cd pithos-web-client
714
        for branch in `git branch -a | grep remotes | grep -v HEAD`; do
715
            git branch --track ${branch##*/} $branch > /dev/null 2>&1
716
        done
717
        git branch
718
        """
719
        webclient_branches = _run(cmd, False)
720
        webclient_branches = webclient_branches.split()
721

    
722
        # If we have pithos_webclient_branch in config file use this one
723
        # else try to use the same branch as synnefo_branch
724
        # else use an appropriate one.
725
        if pithos_webclient_branch == "":
726
            if synnefo_branch in webclient_branches:
727
                pithos_webclient_branch = synnefo_branch
728
            else:
729
                # If synnefo_branch starts with one of
730
                # 'master', 'hotfix'; use the master branch
731
                if synnefo_branch.startswith('master') or \
732
                        synnefo_branch.startswith('hotfix'):
733
                    pithos_webclient_branch = "master"
734
                # If synnefo_branch starts with one of
735
                # 'develop', 'feature'; use the develop branch
736
                elif synnefo_branch.startswith('develop') or \
737
                        synnefo_branch.startswith('feature'):
738
                    pithos_webclient_branch = "develop"
739
                else:
740
                    self.logger.warning(
741
                        "Cannot determine which pithos-web-client branch to "
742
                        "use based on \"%s\" synnefo branch. "
743
                        "Will use develop." % synnefo_branch)
744
                    pithos_webclient_branch = "develop"
745
        # Checkout branch
746
        self.logger.debug("Checkout \"%s\" branch" %
747
                          _green(pithos_webclient_branch))
748
        cmd = """
749
        cd pithos-web-client
750
        git checkout {0}
751
        """.format(pithos_webclient_branch)
752
        _run(cmd, False)
753

    
754
    def _git_clone(self, repo):
755
        """Clone repo to remote server
756

757
        Currently clonning from code.grnet.gr can fail unexpectedly.
758
        So retry!!
759

760
        """
761
        cloned = False
762
        for i in range(1, 11):
763
            try:
764
                _run("git clone %s" % repo, False)
765
                cloned = True
766
                break
767
            except BaseException:
768
                self.logger.warning("Clonning failed.. retrying %s/10" % i)
769
        if not cloned:
770
            self.logger.error("Can not clone repo.")
771
            sys.exit(1)
772

    
773
    @_check_fabric
774
    def build_packages(self):
775
        """Build packages needed by Synnefo software"""
776
        self.logger.info("Install development packages")
777
        cmd = """
778
        apt-get update
779
        apt-get install zlib1g-dev dpkg-dev debhelper git-buildpackage \
780
                python-dev python-all python-pip ant --yes --force-yes
781
        pip install -U devflow
782
        """
783
        _run(cmd, False)
784

    
785
        # Patch pydist bug
786
        if self.config.get('Global', 'patch_pydist') == "True":
787
            self.logger.debug("Patch pydist.py module")
788
            cmd = r"""
789
            sed -r -i 's/(\(\?P<name>\[A-Za-z\]\[A-Za-z0-9_\.)/\1\\\-/' \
790
                /usr/share/python/debpython/pydist.py
791
            """
792
            _run(cmd, False)
793

794
        # Build synnefo packages
795
        self.build_synnefo()
796
        # Build pithos-web-client packages
797
        self.build_pithos_webclient()
798

799
    @_check_fabric
800
    def build_synnefo(self):
801
        """Build Synnefo packages"""
802
        self.logger.info("Build Synnefo packages..")
803

804
        cmd = """
805
        devflow-autopkg snapshot -b ~/synnefo_build-area --no-sign
806
        """
807
        with fabric.cd("synnefo"):
808
            _run(cmd, True)
809

810
        # Install snf-deploy package
811
        self.logger.debug("Install snf-deploy package")
812
        cmd = """
813
        dpkg -i snf-deploy*.deb
814
        apt-get -f install --yes --force-yes
815
        """
816
        with fabric.cd("synnefo_build-area"):
817
            with fabric.settings(warn_only=True):
818
                _run(cmd, True)
819

820
        # Setup synnefo packages for snf-deploy
821
        self.logger.debug("Copy synnefo debs to snf-deploy packages dir")
822
        cmd = """
823
        cp ~/synnefo_build-area/*.deb /var/lib/snf-deploy/packages/
824
        """
825
        _run(cmd, False)
826

827
    @_check_fabric
828
    def build_pithos_webclient(self):
829
        """Build pithos-web-client packages"""
830
        self.logger.info("Build pithos-web-client packages..")
831

832
        cmd = """
833
        devflow-autopkg snapshot -b ~/webclient_build-area --no-sign
834
        """
835
        with fabric.cd("pithos-web-client"):
836
            _run(cmd, True)
837

838
        # Setup pithos-web-client packages for snf-deploy
839
        self.logger.debug("Copy webclient debs to snf-deploy packages dir")
840
        cmd = """
841
        cp ~/webclient_build-area/*.deb /var/lib/snf-deploy/packages/
842
        """
843
        _run(cmd, False)
844

845
    @_check_fabric
846
    def build_documentation(self):
847
        """Build Synnefo documentation"""
848
        self.logger.info("Build Synnefo documentation..")
849
        _run("pip install -U Sphinx", False)
850
        with fabric.cd("synnefo"):
851
            _run("devflow-update-version; "
852
                 "./ci/make_docs.sh synnefo_documentation", False)
853

854
    def fetch_documentation(self, dest=None):
855
        """Fetch Synnefo documentation"""
856
        self.logger.info("Fetch Synnefo documentation..")
857
        if dest is None:
858
            dest = "synnefo_documentation"
859
        dest = os.path.abspath(dest)
860
        if not os.path.exists(dest):
861
            os.makedirs(dest)
862
        self.fetch_compressed("synnefo/synnefo_documentation", dest)
863
        self.logger.info("Downloaded documentation to %s" %
864
                         _green(dest))
865

866
    @_check_fabric
867
    def deploy_synnefo(self, schema=None):
868
        """Deploy Synnefo using snf-deploy"""
869
        self.logger.info("Deploy Synnefo..")
870
        if schema is None:
871
            schema = self.config.get('Global', 'schema')
872
        self.logger.debug("Will use \"%s\" schema" % _green(schema))
873

874
        schema_dir = os.path.join(self.ci_dir, "schemas/%s" % schema)
875
        if not (os.path.exists(schema_dir) and os.path.isdir(schema_dir)):
876
            raise ValueError("Unknown schema: %s" % schema)
877

878
        self.logger.debug("Upload schema files to server")
879
        _put(os.path.join(schema_dir, "*"), "/etc/snf-deploy/")
880

881
        self.logger.debug("Change password in nodes.conf file")
882
        cmd = """
883
        sed -i 's/^password =.*/password = {0}/' /etc/snf-deploy/nodes.conf
884
        """.format(fabric.env.password)
885
        _run(cmd, False)
886

887
        self.logger.debug("Run snf-deploy")
888
        cmd = """
889
        snf-deploy keygen --force
890
        snf-deploy --disable-colors --autoconf all
891
        """
892
        _run(cmd, True)
893

894
    @_check_fabric
895
    def unit_test(self):
896
        """Run Synnefo unit test suite"""
897
        self.logger.info("Run Synnefo unit test suite")
898
        component = self.config.get('Unit Tests', 'component')
899

900
        self.logger.debug("Install needed packages")
901
        cmd = """
902
        pip install -U mock
903
        pip install -U factory_boy
904
        pip install -U nose
905
        """
906
        _run(cmd, False)
907

908
        self.logger.debug("Upload tests.sh file")
909
        unit_tests_file = os.path.join(self.ci_dir, "tests.sh")
910
        _put(unit_tests_file, ".")
911

912
        self.logger.debug("Run unit tests")
913
        cmd = """
914
        bash tests.sh {0}
915
        """.format(component)
916
        _run(cmd, True)
917

918
    @_check_fabric
919
    def run_burnin(self):
920
        """Run burnin functional test suite"""
921
        self.logger.info("Run Burnin functional test suite")
922
        cmd = """
923
        auth_url=$(grep -e '^url =' .kamakirc | cut -d' ' -f3)
924
        token=$(grep -e '^token =' .kamakirc | cut -d' ' -f3)
925
        images_user=$(kamaki image list -l | grep owner | \
926
                      cut -d':' -f2 | tr -d ' ')
927
        snf-burnin --auth-url=$auth_url --token=$token {0}
928
        BurninExitStatus=$?
929
        exit $BurninExitStatus
930
        """.format(self.config.get('Burnin', 'cmd_options'))
931
        _run(cmd, True)
932

933
    @_check_fabric
934
    def fetch_compressed(self, src, dest=None):
935
        """Create a tarball and fetch it locally"""
936
        self.logger.debug("Creating tarball of %s" % src)
937
        basename = os.path.basename(src)
938
        tar_file = basename + ".tgz"
939
        cmd = "tar czf %s %s" % (tar_file, src)
940
        _run(cmd, False)
941
        if not os.path.exists(dest):
942
            os.makedirs(dest)
943

944
        tmp_dir = tempfile.mkdtemp()
945
        fabric.get(tar_file, tmp_dir)
946

947
        dest_file = os.path.join(tmp_dir, tar_file)
948
        self._check_hash_sum(dest_file, tar_file)
949
        self.logger.debug("Untar packages file %s" % dest_file)
950
        cmd = """
951
        cd %s
952
        tar xzf %s
953
        cp -r %s/* %s
954
        rm -r %s
955
        """ % (tmp_dir, tar_file, src, dest, tmp_dir)
956
        os.system(cmd)
957
        self.logger.info("Downloaded %s to %s" %
958
                         (src, _green(dest)))
959

960
    @_check_fabric
961
    def fetch_packages(self, dest=None):
962
        """Fetch Synnefo packages"""
963
        if dest is None:
964
            dest = self.config.get('Global', 'pkgs_dir')
965
        dest = os.path.abspath(os.path.expanduser(dest))
966
        if not os.path.exists(dest):
967
            os.makedirs(dest)
968
        self.fetch_compressed("synnefo_build-area", dest)
969
        self.fetch_compressed("webclient_build-area", dest)
970
        self.logger.info("Downloaded debian packages to %s" %
971
                         _green(dest))
972

973
    def x2go_plugin(self, dest=None):
974
        """Produce an html page which will use the x2goplugin
975

    
976
        Arguments:
977
          dest  -- The file where to save the page (String)
978

    
979
        """
980
        output_str = """
981
        <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
982
        <html>
983
        <head>
984
        <title>X2Go SynnefoCI Service</title>
985
        </head>
986
        <body onload="checkPlugin()">
987
        <div id="x2goplugin">
988
            <object
989
                src="location"
990
                type="application/x2go"
991
                name="x2goplugin"
992
                palette="background"
993
                height="100%"
994
                hspace="0"
995
                vspace="0"
996
                width="100%"
997
                x2goconfig="
998
                    session=X2Go-SynnefoCI-Session
999
                    server={0}
1000
                    user={1}
1001
                    sshport={2}
1002
                    published=true
1003
                    autologin=true
1004
                ">
1005
            </object>
1006
        </div>
1007
        </body>
1008
        </html>
1009
        """.format(self.read_temp_config('server_ip'),
1010
                   self.read_temp_config('server_user'),
1011
                   self.read_temp_config('server_port'))
1012
        if dest is None:
1013
            dest = self.config.get('Global', 'x2go_plugin_file')
1014

1015
        self.logger.info("Writting x2go plugin html file to %s" % dest)
1016
        fid = open(dest, 'w')
1017
        fid.write(output_str)
1018
        fid.close()
1019

1020

1021
def parse_typed_option(option, value):
1022
    """Parsed typed options (flavors and images)"""
1023
    try:
1024
        [type_, val] = value.strip().split(':')
1025
        if type_ not in ["id", "name"]:
1026
            raise ValueError
1027
        return type_, val
1028
    except ValueError:
1029
        msg = "Invalid %s format. Must be [id|name]:.+" % option
1030
        raise ValueError(msg)
1031