Statistics
| Branch: | Tag: | Revision:

root / ci / utils.py @ bbc826ca

History | View | Annotate | Download (39.8 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 = [p.strip() for p in private_networks.split(",")]
310
            networks.extend([{"uuid": uuid} for uuid in private_networks])
311
        if server_name is None:
312
            server_name = self.config.get("Deployment", "server_name")
313
            server_name = "%s(BID: %s)" % (server_name, self.build_id)
314
        server = self.cyclades_client.create_server(
315
            server_name, flavor_id, image_id, networks=networks)
316
        server_id = server['id']
317
        self.write_temp_config('server_id', server_id)
318
        self.logger.debug("Server got id %s" % _green(server_id))
319
        server_user = server['metadata']['users']
320
        self.write_temp_config('server_user', server_user)
321
        self.logger.debug("Server's admin user is %s" % _green(server_user))
322
        server_passwd = server['adminPass']
323
        self.write_temp_config('server_passwd', server_passwd)
324

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    
470
    def _get_server_ip_and_port(self, server, private_networks):
471
        """Compute server's IPv4 and ssh port number"""
472
        self.logger.info("Get server connection details..")
473
        if private_networks:
474
            # Choose the networks that belong to private_networks
475
            networks = [n for n in server['attachments']
476
                        if n['network_id'] in private_networks]
477
        else:
478
            # Choose the networks that are public
479
            networks = \
480
                [n for n in server['attachments']
481
                 if self.network_client.get_network_details(n['id'])['public']]
482
        # Choose the networks with IPv4
483
        networks = [n for n in networks if n['ipv4']]
484
        # Use the first network as IPv4
485
        server_ip = networks[0]['ipv4']
486

    
487
        if (".okeanos.io" in self.cyclades_client.base_url or
488
           ".demo.synnefo.org" in self.cyclades_client.base_url):
489
            tmp1 = int(server_ip.split(".")[2])
490
            tmp2 = int(server_ip.split(".")[3])
491
            server_ip = "gate.okeanos.io"
492
            server_port = 10000 + tmp1 * 256 + tmp2
493
        else:
494
            server_port = 22
495
        self.write_temp_config('server_ip', server_ip)
496
        self.logger.debug("Server's IPv4 is %s" % _green(server_ip))
497
        self.write_temp_config('server_port', server_port)
498
        self.logger.debug("Server's ssh port is %s" % _green(server_port))
499
        ssh_command = "ssh -p %s %s@%s" \
500
            % (server_port, server['metadata']['users'], server_ip)
501
        self.logger.debug("Access server using \"%s\"" %
502
                          (_green(ssh_command)))
503

    
504
    @_check_fabric
505
    def _copy_ssh_keys(self, ssh_keys):
506
        """Upload/Install ssh keys to server"""
507
        self.logger.debug("Check for authentication keys to use")
508
        if ssh_keys is None:
509
            ssh_keys = self.config.get("Deployment", "ssh_keys")
510

    
511
        if ssh_keys != "":
512
            ssh_keys = os.path.expanduser(ssh_keys)
513
            self.logger.debug("Will use \"%s\" authentication keys file" %
514
                              _green(ssh_keys))
515
            keyfile = '/tmp/%s.pub' % fabric.env.user
516
            _run('mkdir -p ~/.ssh && chmod 700 ~/.ssh', False)
517
            if ssh_keys.startswith("http://") or \
518
                    ssh_keys.startswith("https://") or \
519
                    ssh_keys.startswith("ftp://"):
520
                cmd = """
521
                apt-get update
522
                apt-get install wget --yes --force-yes
523
                wget {0} -O {1} --no-check-certificate
524
                """.format(ssh_keys, keyfile)
525
                _run(cmd, False)
526
            elif os.path.exists(ssh_keys):
527
                _put(ssh_keys, keyfile)
528
            else:
529
                self.logger.debug("No ssh keys found")
530
                return
531
            _run('cat %s >> ~/.ssh/authorized_keys' % keyfile, False)
532
            _run('rm %s' % keyfile, False)
533
            self.logger.debug("Uploaded ssh authorized keys")
534
        else:
535
            self.logger.debug("No ssh keys found")
536

    
537
    def _create_new_build_id(self):
538
        """Find a uniq build_id to use"""
539
        with filelocker.lock("%s.lock" % self.temp_config_file,
540
                             filelocker.LOCK_EX):
541
            # Read temp_config again to get any new entries
542
            self.temp_config.read(self.temp_config_file)
543

    
544
            # Find a uniq build_id to use
545
            if self.build_id is None:
546
                ids = self.temp_config.sections()
547
                if ids:
548
                    max_id = int(max(self.temp_config.sections(), key=int))
549
                    self.build_id = max_id + 1
550
                else:
551
                    self.build_id = 1
552
            self.logger.debug("Will use \"%s\" as build id"
553
                              % _green(self.build_id))
554

    
555
            # Create a new section
556
            try:
557
                self.temp_config.add_section(str(self.build_id))
558
            except DuplicateSectionError:
559
                msg = ("Build id \"%s\" already in use. " +
560
                       "Please use a uniq one or cleanup \"%s\" file.\n") \
561
                    % (self.build_id, self.temp_config_file)
562
                self.logger.error(msg)
563
                sys.exit(1)
564
            creation_time = \
565
                time.strftime("%a, %d %b %Y %X", time.localtime())
566
            self.temp_config.set(str(self.build_id),
567
                                 "created", str(creation_time))
568

    
569
            # Write changes back to temp config file
570
            with open(self.temp_config_file, 'wb') as tcf:
571
                self.temp_config.write(tcf)
572

    
573
    def write_temp_config(self, option, value):
574
        """Write changes back to config file"""
575
        # Acquire the lock to write to temp_config_file
576
        with filelocker.lock("%s.lock" % self.temp_config_file,
577
                             filelocker.LOCK_EX):
578

    
579
            # Read temp_config again to get any new entries
580
            self.temp_config.read(self.temp_config_file)
581

    
582
            self.temp_config.set(str(self.build_id), option, str(value))
583
            curr_time = time.strftime("%a, %d %b %Y %X", time.localtime())
584
            self.temp_config.set(str(self.build_id), "modified", curr_time)
585

    
586
            # Write changes back to temp config file
587
            with open(self.temp_config_file, 'wb') as tcf:
588
                self.temp_config.write(tcf)
589

    
590
    def read_temp_config(self, option):
591
        """Read from temporary_config file"""
592
        # If build_id is None use the latest one
593
        if self.build_id is None:
594
            ids = self.temp_config.sections()
595
            if ids:
596
                self.build_id = int(ids[-1])
597
            else:
598
                self.logger.error("No sections in temporary config file")
599
                sys.exit(1)
600
            self.logger.debug("Will use \"%s\" as build id"
601
                              % _green(self.build_id))
602
        # Read specified option
603
        return self.temp_config.get(str(self.build_id), option)
604

    
605
    def setup_fabric(self):
606
        """Setup fabric environment"""
607
        self.logger.info("Setup fabric parameters..")
608
        fabric.env.user = self.read_temp_config('server_user')
609
        fabric.env.host_string = self.read_temp_config('server_ip')
610
        fabric.env.port = int(self.read_temp_config('server_port'))
611
        fabric.env.password = self.read_temp_config('server_passwd')
612
        fabric.env.connection_attempts = 10
613
        fabric.env.shell = "/bin/bash -c"
614
        fabric.env.disable_known_hosts = True
615
        fabric.env.output_prefix = None
616

    
617
    def _check_hash_sum(self, localfile, remotefile):
618
        """Check hash sums of two files"""
619
        self.logger.debug("Check hash sum for local file %s" % localfile)
620
        hash1 = os.popen("sha256sum %s" % localfile).read().split(' ')[0]
621
        self.logger.debug("Local file has sha256 hash %s" % hash1)
622
        self.logger.debug("Check hash sum for remote file %s" % remotefile)
623
        hash2 = _run("sha256sum %s" % remotefile, False)
624
        hash2 = hash2.split(' ')[0]
625
        self.logger.debug("Remote file has sha256 hash %s" % hash2)
626
        if hash1 != hash2:
627
            self.logger.error("Hashes differ.. aborting")
628
            sys.exit(1)
629

    
630
    @_check_fabric
631
    def clone_repo(self, local_repo=False):
632
        """Clone Synnefo repo from slave server"""
633
        self.logger.info("Configure repositories on remote server..")
634
        self.logger.debug("Install/Setup git")
635
        cmd = """
636
        apt-get install git --yes --force-yes
637
        git config --global user.name {0}
638
        git config --global user.email {1}
639
        """.format(self.config.get('Global', 'git_config_name'),
640
                   self.config.get('Global', 'git_config_mail'))
641
        _run(cmd, False)
642

    
643
        # Clone synnefo_repo
644
        synnefo_branch = self.clone_synnefo_repo(local_repo=local_repo)
645
        # Clone pithos-web-client
646
        self.clone_pithos_webclient_repo(synnefo_branch)
647

    
648
    @_check_fabric
649
    def clone_synnefo_repo(self, local_repo=False):
650
        """Clone Synnefo repo to remote server"""
651
        # Find synnefo_repo and synnefo_branch to use
652
        synnefo_repo = self.config.get('Global', 'synnefo_repo')
653
        synnefo_branch = self.config.get("Global", "synnefo_branch")
654
        if synnefo_branch == "":
655
            synnefo_branch = \
656
                subprocess.Popen(
657
                    ["git", "rev-parse", "--abbrev-ref", "HEAD"],
658
                    stdout=subprocess.PIPE).communicate()[0].strip()
659
            if synnefo_branch == "HEAD":
660
                synnefo_branch = \
661
                    subprocess.Popen(
662
                        ["git", "rev-parse", "--short", "HEAD"],
663
                        stdout=subprocess.PIPE).communicate()[0].strip()
664
        self.logger.debug("Will use branch \"%s\"" % _green(synnefo_branch))
665

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

    
696
        # Checkout the desired synnefo_branch
697
        self.logger.debug("Checkout \"%s\" branch/commit" % synnefo_branch)
698
        cmd = """
699
        cd synnefo
700
        for branch in `git branch -a | grep remotes | grep -v HEAD`; do
701
            git branch --track ${branch##*/} $branch
702
        done
703
        git checkout %s
704
        """ % (synnefo_branch)
705
        _run(cmd, False)
706

    
707
        return synnefo_branch
708

    
709
    @_check_fabric
710
    def clone_pithos_webclient_repo(self, synnefo_branch):
711
        """Clone Pithos WebClient repo to remote server"""
712
        # Find pithos_webclient_repo and pithos_webclient_branch to use
713
        pithos_webclient_repo = \
714
            self.config.get('Global', 'pithos_webclient_repo')
715
        pithos_webclient_branch = \
716
            self.config.get('Global', 'pithos_webclient_branch')
717

    
718
        # Clone pithos-webclient from remote repo
719
        self.logger.debug("Clone pithos-webclient from %s" %
720
                          pithos_webclient_repo)
721
        self._git_clone(pithos_webclient_repo)
722

    
723
        # Track all pithos-webclient branches
724
        cmd = """
725
        cd pithos-web-client
726
        for branch in `git branch -a | grep remotes | grep -v HEAD`; do
727
            git branch --track ${branch##*/} $branch > /dev/null 2>&1
728
        done
729
        git branch
730
        """
731
        webclient_branches = _run(cmd, False)
732
        webclient_branches = webclient_branches.split()
733

    
734
        # If we have pithos_webclient_branch in config file use this one
735
        # else try to use the same branch as synnefo_branch
736
        # else use an appropriate one.
737
        if pithos_webclient_branch == "":
738
            if synnefo_branch in webclient_branches:
739
                pithos_webclient_branch = synnefo_branch
740
            else:
741
                # If synnefo_branch starts with one of
742
                # 'master', 'hotfix'; use the master branch
743
                if synnefo_branch.startswith('master') or \
744
                        synnefo_branch.startswith('hotfix'):
745
                    pithos_webclient_branch = "master"
746
                # If synnefo_branch starts with one of
747
                # 'develop', 'feature'; use the develop branch
748
                elif synnefo_branch.startswith('develop') or \
749
                        synnefo_branch.startswith('feature'):
750
                    pithos_webclient_branch = "develop"
751
                else:
752
                    self.logger.warning(
753
                        "Cannot determine which pithos-web-client branch to "
754
                        "use based on \"%s\" synnefo branch. "
755
                        "Will use develop." % synnefo_branch)
756
                    pithos_webclient_branch = "develop"
757
        # Checkout branch
758
        self.logger.debug("Checkout \"%s\" branch" %
759
                          _green(pithos_webclient_branch))
760
        cmd = """
761
        cd pithos-web-client
762
        git checkout {0}
763
        """.format(pithos_webclient_branch)
764
        _run(cmd, False)
765

    
766
    def _git_clone(self, repo):
767
        """Clone repo to remote server
768

769
        Currently clonning from code.grnet.gr can fail unexpectedly.
770
        So retry!!
771

772
        """
773
        cloned = False
774
        for i in range(1, 11):
775
            try:
776
                _run("git clone %s" % repo, False)
777
                cloned = True
778
                break
779
            except BaseException:
780
                self.logger.warning("Clonning failed.. retrying %s/10" % i)
781
        if not cloned:
782
            self.logger.error("Can not clone repo.")
783
            sys.exit(1)
784

    
785
    @_check_fabric
786
    def build_packages(self):
787
        """Build packages needed by Synnefo software"""
788
        self.logger.info("Install development packages")
789
        cmd = """
790
        apt-get update
791
        apt-get install zlib1g-dev dpkg-dev debhelper git-buildpackage \
792
                python-dev python-all python-pip ant --yes --force-yes
793
        pip install -U devflow
794
        """
795
        _run(cmd, False)
796

    
797
        # Patch pydist bug
798
        if self.config.get('Global', 'patch_pydist') == "True":
799
            self.logger.debug("Patch pydist.py module")
800
            cmd = r"""
801
            sed -r -i 's/(\(\?P<name>\[A-Za-z\]\[A-Za-z0-9_\.)/\1\\\-/' \
802
                /usr/share/python/debpython/pydist.py
803
            """
804
            _run(cmd, False)
805

806
        # Build synnefo packages
807
        self.build_synnefo()
808
        # Build pithos-web-client packages
809
        self.build_pithos_webclient()
810

811
    @_check_fabric
812
    def build_synnefo(self):
813
        """Build Synnefo packages"""
814
        self.logger.info("Build Synnefo packages..")
815

816
        cmd = """
817
        devflow-autopkg snapshot -b ~/synnefo_build-area --no-sign
818
        """
819
        with fabric.cd("synnefo"):
820
            _run(cmd, True)
821

822
        # Install snf-deploy package
823
        self.logger.debug("Install snf-deploy package")
824
        cmd = """
825
        dpkg -i snf-deploy*.deb
826
        apt-get -f install --yes --force-yes
827
        """
828
        with fabric.cd("synnefo_build-area"):
829
            with fabric.settings(warn_only=True):
830
                _run(cmd, True)
831

832
        # Setup synnefo packages for snf-deploy
833
        self.logger.debug("Copy synnefo debs to snf-deploy packages dir")
834
        cmd = """
835
        cp ~/synnefo_build-area/*.deb /var/lib/snf-deploy/packages/
836
        """
837
        _run(cmd, False)
838

839
    @_check_fabric
840
    def build_pithos_webclient(self):
841
        """Build pithos-web-client packages"""
842
        self.logger.info("Build pithos-web-client packages..")
843

844
        cmd = """
845
        devflow-autopkg snapshot -b ~/webclient_build-area --no-sign
846
        """
847
        with fabric.cd("pithos-web-client"):
848
            _run(cmd, True)
849

850
        # Setup pithos-web-client packages for snf-deploy
851
        self.logger.debug("Copy webclient debs to snf-deploy packages dir")
852
        cmd = """
853
        cp ~/webclient_build-area/*.deb /var/lib/snf-deploy/packages/
854
        """
855
        _run(cmd, False)
856

857
    @_check_fabric
858
    def build_documentation(self):
859
        """Build Synnefo documentation"""
860
        self.logger.info("Build Synnefo documentation..")
861
        _run("pip install -U Sphinx", False)
862
        with fabric.cd("synnefo"):
863
            _run("devflow-update-version; "
864
                 "./ci/make_docs.sh synnefo_documentation", False)
865

866
    def fetch_documentation(self, dest=None):
867
        """Fetch Synnefo documentation"""
868
        self.logger.info("Fetch Synnefo documentation..")
869
        if dest is None:
870
            dest = "synnefo_documentation"
871
        dest = os.path.abspath(dest)
872
        if not os.path.exists(dest):
873
            os.makedirs(dest)
874
        self.fetch_compressed("synnefo/synnefo_documentation", dest)
875
        self.logger.info("Downloaded documentation to %s" %
876
                         _green(dest))
877

878
    @_check_fabric
879
    def deploy_synnefo(self, schema=None):
880
        """Deploy Synnefo using snf-deploy"""
881
        self.logger.info("Deploy Synnefo..")
882
        if schema is None:
883
            schema = self.config.get('Global', 'schema')
884
        self.logger.debug("Will use \"%s\" schema" % _green(schema))
885

886
        schema_dir = os.path.join(self.ci_dir, "schemas/%s" % schema)
887
        if not (os.path.exists(schema_dir) and os.path.isdir(schema_dir)):
888
            raise ValueError("Unknown schema: %s" % schema)
889

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

893
        self.logger.debug("Change password in nodes.conf file")
894
        cmd = """
895
        sed -i 's/^password =.*/password = {0}/' /etc/snf-deploy/nodes.conf
896
        """.format(fabric.env.password)
897
        _run(cmd, False)
898

899
        self.logger.debug("Run snf-deploy")
900
        cmd = """
901
        snf-deploy keygen --force
902
        snf-deploy --disable-colors --autoconf all
903
        """
904
        _run(cmd, True)
905

906
    @_check_fabric
907
    def unit_test(self):
908
        """Run Synnefo unit test suite"""
909
        self.logger.info("Run Synnefo unit test suite")
910
        component = self.config.get('Unit Tests', 'component')
911

912
        self.logger.debug("Install needed packages")
913
        cmd = """
914
        pip install -U mock
915
        pip install -U factory_boy
916
        pip install -U nose
917
        """
918
        _run(cmd, False)
919

920
        self.logger.debug("Upload tests.sh file")
921
        unit_tests_file = os.path.join(self.ci_dir, "tests.sh")
922
        _put(unit_tests_file, ".")
923

924
        self.logger.debug("Run unit tests")
925
        cmd = """
926
        bash tests.sh {0}
927
        """.format(component)
928
        _run(cmd, True)
929

930
    @_check_fabric
931
    def run_burnin(self):
932
        """Run burnin functional test suite"""
933
        self.logger.info("Run Burnin functional test suite")
934
        cmd = """
935
        auth_url=$(grep -e '^url =' .kamakirc | cut -d' ' -f3)
936
        token=$(grep -e '^token =' .kamakirc | cut -d' ' -f3)
937
        images_user=$(kamaki image list -l | grep owner | \
938
                      cut -d':' -f2 | tr -d ' ')
939
        snf-burnin --auth-url=$auth_url --token=$token {0}
940
        BurninExitStatus=$?
941
        exit $BurninExitStatus
942
        """.format(self.config.get('Burnin', 'cmd_options'))
943
        _run(cmd, True)
944

945
    @_check_fabric
946
    def fetch_compressed(self, src, dest=None):
947
        """Create a tarball and fetch it locally"""
948
        self.logger.debug("Creating tarball of %s" % src)
949
        basename = os.path.basename(src)
950
        tar_file = basename + ".tgz"
951
        cmd = "tar czf %s %s" % (tar_file, src)
952
        _run(cmd, False)
953
        if not os.path.exists(dest):
954
            os.makedirs(dest)
955

956
        tmp_dir = tempfile.mkdtemp()
957
        fabric.get(tar_file, tmp_dir)
958

959
        dest_file = os.path.join(tmp_dir, tar_file)
960
        self._check_hash_sum(dest_file, tar_file)
961
        self.logger.debug("Untar packages file %s" % dest_file)
962
        cmd = """
963
        cd %s
964
        tar xzf %s
965
        cp -r %s/* %s
966
        rm -r %s
967
        """ % (tmp_dir, tar_file, src, dest, tmp_dir)
968
        os.system(cmd)
969
        self.logger.info("Downloaded %s to %s" %
970
                         (src, _green(dest)))
971

972
    @_check_fabric
973
    def fetch_packages(self, dest=None):
974
        """Fetch Synnefo packages"""
975
        if dest is None:
976
            dest = self.config.get('Global', 'pkgs_dir')
977
        dest = os.path.abspath(os.path.expanduser(dest))
978
        if not os.path.exists(dest):
979
            os.makedirs(dest)
980
        self.fetch_compressed("synnefo_build-area", dest)
981
        self.fetch_compressed("webclient_build-area", dest)
982
        self.logger.info("Downloaded debian packages to %s" %
983
                         _green(dest))
984

985
    def x2go_plugin(self, dest=None):
986
        """Produce an html page which will use the x2goplugin
987

    
988
        Arguments:
989
          dest  -- The file where to save the page (String)
990

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

1027
        self.logger.info("Writting x2go plugin html file to %s" % dest)
1028
        fid = open(dest, 'w')
1029
        fid.write(output_str)
1030
        fid.close()
1031

1032

1033
def parse_typed_option(option, value):
1034
    """Parsed typed options (flavors and images)"""
1035
    try:
1036
        [type_, val] = value.strip().split(':')
1037
        if type_ not in ["id", "name"]:
1038
            raise ValueError
1039
        return type_, val
1040
    except ValueError:
1041
        msg = "Invalid %s format. Must be [id|name]:.+" % option
1042
        raise ValueError(msg)
1043