Statistics
| Branch: | Tag: | Revision:

root / ci / utils.py @ 94c89c0e

History | View | Annotate | Download (38.7 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
        self.logger.info("Destoying server with id %s " % server_id)
257
        self.cyclades_client.delete_server(server_id)
258
        if wait:
259
            self._wait_transition(server_id, "ACTIVE", "DELETED")
260

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

    
272
    def _create_port(self, floating_ip):
273
        """Create a new port for our floating IP"""
274
        net_id = floating_ip['floating_network_id']
275
        self.logger.debug("Creating a new port to network with id %s", net_id)
276
        fixed_ips = [{'ip_address': floating_ip['floating_ip_address']}]
277
        port = self.network_client.create_port(
278
            net_id, device_id=None, fixed_ips=fixed_ips)
279
        return port
280

    
281
    @_check_kamaki
282
    def create_server(self, image=None, flavor=None, ssh_keys=None,
283
                      server_name=None):
284
        """Create slave server"""
285
        self.logger.info("Create a new server..")
286

    
287
        # Find a build_id to use
288
        self._create_new_build_id()
289

    
290
        # Find an image to use
291
        image_id = self._find_image(image)
292
        # Find a flavor to use
293
        flavor_id = self._find_flavor(flavor)
294

    
295
        # Create Server
296
        fip = self._create_floating_ip()
297
        port = self._create_port(fip)
298
        networks = [{'port': port['id']}]
299
        if server_name is None:
300
            server_name = self.config.get("Deployment", "server_name")
301
            server_name = "%s(BID: %s)" % (server_name, self.build_id)
302
        server = self.cyclades_client.create_server(
303
            server_name, flavor_id, image_id, networks=networks)
304
        server_id = server['id']
305
        self.write_temp_config('server_id', server_id)
306
        self.logger.debug("Server got id %s" % _green(server_id))
307
        server_user = server['metadata']['users']
308
        self.write_temp_config('server_user', server_user)
309
        self.logger.debug("Server's admin user is %s" % _green(server_user))
310
        server_passwd = server['adminPass']
311
        self.write_temp_config('server_passwd', server_passwd)
312

    
313
        server = self._wait_transition(server_id, "BUILD", "ACTIVE")
314
        self._get_server_ip_and_port(server)
315
        self._copy_ssh_keys(ssh_keys)
316

    
317
        # Setup Firewall
318
        self.setup_fabric()
319
        self.logger.info("Setup firewall")
320
        accept_ssh_from = self.config.get('Global', 'accept_ssh_from')
321
        if accept_ssh_from != "":
322
            self.logger.debug("Block ssh except from %s" % accept_ssh_from)
323
            cmd = """
324
            local_ip=$(/sbin/ifconfig eth0 | grep 'inet addr:' | \
325
                cut -d':' -f2 | cut -d' ' -f1)
326
            iptables -A INPUT -s localhost -j ACCEPT
327
            iptables -A INPUT -s $local_ip -j ACCEPT
328
            iptables -A INPUT -s {0} -p tcp --dport 22 -j ACCEPT
329
            iptables -A INPUT -p tcp --dport 22 -j DROP
330
            """.format(accept_ssh_from)
331
            _run(cmd, False)
332

    
333
        # Setup apt, download packages
334
        self.logger.debug("Setup apt. Install x2goserver and firefox")
335
        cmd = """
336
        echo 'APT::Install-Suggests "false";' >> /etc/apt/apt.conf
337
        echo 'precedence ::ffff:0:0/96  100' >> /etc/gai.conf
338
        apt-get update
339
        apt-get install curl --yes --force-yes
340
        echo -e "\n\n{0}" >> /etc/apt/sources.list
341
        # Synnefo repo's key
342
        curl https://dev.grnet.gr/files/apt-grnetdev.pub | apt-key add -
343

344
        # X2GO Key
345
        apt-key adv --recv-keys --keyserver keys.gnupg.net E1F958385BFE2B6E
346
        apt-get install x2go-keyring --yes --force-yes
347
        apt-get update
348
        apt-get install x2goserver x2goserver-xsession \
349
                iceweasel --yes --force-yes
350

351
        # xterm published application
352
        echo '[Desktop Entry]' > /usr/share/applications/xterm.desktop
353
        echo 'Name=XTerm' >> /usr/share/applications/xterm.desktop
354
        echo 'Comment=standard terminal emulator for the X window system' >> \
355
            /usr/share/applications/xterm.desktop
356
        echo 'Exec=xterm' >> /usr/share/applications/xterm.desktop
357
        echo 'Terminal=false' >> /usr/share/applications/xterm.desktop
358
        echo 'Type=Application' >> /usr/share/applications/xterm.desktop
359
        echo 'Encoding=UTF-8' >> /usr/share/applications/xterm.desktop
360
        echo 'Icon=xterm-color_48x48' >> /usr/share/applications/xterm.desktop
361
        echo 'Categories=System;TerminalEmulator;' >> \
362
                /usr/share/applications/xterm.desktop
363
        """.format(self.config.get('Global', 'apt_repo'))
364
        _run(cmd, False)
365

    
366
    def _find_flavor(self, flavor=None):
367
        """Find a suitable flavor to use
368

369
        Search by name (reg expression) or by id
370
        """
371
        # Get a list of flavors from config file
372
        flavors = self.config.get('Deployment', 'flavors').split(",")
373
        if flavor is not None:
374
            # If we have a flavor_name to use, add it to our list
375
            flavors.insert(0, flavor)
376

    
377
        list_flavors = self.compute_client.list_flavors()
378
        for flv in flavors:
379
            flv_type, flv_value = parse_typed_option(option="flavor",
380
                                                     value=flv)
381
            if flv_type == "name":
382
                # Filter flavors by name
383
                self.logger.debug(
384
                    "Trying to find a flavor with name \"%s\"" % flv_value)
385
                list_flvs = \
386
                    [f for f in list_flavors
387
                     if re.search(flv_value, f['name'], flags=re.I)
388
                     is not None]
389
            elif flv_type == "id":
390
                # Filter flavors by id
391
                self.logger.debug(
392
                    "Trying to find a flavor with id \"%s\"" % flv_value)
393
                list_flvs = \
394
                    [f for f in list_flavors
395
                     if str(f['id']) == flv_value]
396
            else:
397
                self.logger.error("Unrecognized flavor type %s" % flv_type)
398

    
399
            # Check if we found one
400
            if list_flvs:
401
                self.logger.debug("Will use \"%s\" with id \"%s\""
402
                                  % (_green(list_flvs[0]['name']),
403
                                     _green(list_flvs[0]['id'])))
404
                return list_flvs[0]['id']
405

    
406
        self.logger.error("No matching flavor found.. aborting")
407
        sys.exit(1)
408

    
409
    def _find_image(self, image=None):
410
        """Find a suitable image to use
411

412
        In case of search by name, the image has to belong to one
413
        of the `DEFAULT_SYSTEM_IMAGES_UUID' users.
414
        In case of search by id it only has to exist.
415
        """
416
        # Get a list of images from config file
417
        images = self.config.get('Deployment', 'images').split(",")
418
        if image is not None:
419
            # If we have an image from command line, add it to our list
420
            images.insert(0, image)
421

    
422
        auth = self.astakos_client.authenticate()
423
        user_uuid = auth["access"]["token"]["tenant"]["id"]
424
        list_images = self.image_client.list_public(detail=True)['images']
425
        for img in images:
426
            img_type, img_value = parse_typed_option(option="image", value=img)
427
            if img_type == "name":
428
                # Filter images by name
429
                self.logger.debug(
430
                    "Trying to find an image with name \"%s\"" % img_value)
431
                accepted_uuids = DEFAULT_SYSTEM_IMAGES_UUID + [user_uuid]
432
                list_imgs = \
433
                    [i for i in list_images if i['user_id'] in accepted_uuids
434
                     and
435
                     re.search(img_value, i['name'], flags=re.I) is not None]
436
            elif img_type == "id":
437
                # Filter images by id
438
                self.logger.debug(
439
                    "Trying to find an image with id \"%s\"" % img_value)
440
                list_imgs = \
441
                    [i for i in list_images
442
                     if i['id'].lower() == img_value.lower()]
443
            else:
444
                self.logger.error("Unrecognized image type %s" % img_type)
445
                sys.exit(1)
446

    
447
            # Check if we found one
448
            if list_imgs:
449
                self.logger.debug("Will use \"%s\" with id \"%s\""
450
                                  % (_green(list_imgs[0]['name']),
451
                                     _green(list_imgs[0]['id'])))
452
                return list_imgs[0]['id']
453

    
454
        # We didn't found one
455
        self.logger.error("No matching image found.. aborting")
456
        sys.exit(1)
457

    
458
    def _get_server_ip_and_port(self, server):
459
        """Compute server's IPv4 and ssh port number"""
460
        self.logger.info("Get server connection details..")
461
        server_ip = server['attachments'][0]['ipv4']
462
        if (".okeanos.io" in self.cyclades_client.base_url or
463
           ".demo.synnefo.org" in self.cyclades_client.base_url):
464
            tmp1 = int(server_ip.split(".")[2])
465
            tmp2 = int(server_ip.split(".")[3])
466
            server_ip = "gate.okeanos.io"
467
            server_port = 10000 + tmp1 * 256 + tmp2
468
        else:
469
            server_port = 22
470
        self.write_temp_config('server_ip', server_ip)
471
        self.logger.debug("Server's IPv4 is %s" % _green(server_ip))
472
        self.write_temp_config('server_port', server_port)
473
        self.logger.debug("Server's ssh port is %s" % _green(server_port))
474
        ssh_command = "ssh -p %s %s@%s" \
475
            % (server_port, server['metadata']['users'], server_ip)
476
        self.logger.debug("Access server using \"%s\"" %
477
                          (_green(ssh_command)))
478

    
479
    @_check_fabric
480
    def _copy_ssh_keys(self, ssh_keys):
481
        """Upload/Install ssh keys to server"""
482
        self.logger.debug("Check for authentication keys to use")
483
        if ssh_keys is None:
484
            ssh_keys = self.config.get("Deployment", "ssh_keys")
485

    
486
        if ssh_keys != "":
487
            ssh_keys = os.path.expanduser(ssh_keys)
488
            self.logger.debug("Will use \"%s\" authentication keys file" %
489
                              _green(ssh_keys))
490
            keyfile = '/tmp/%s.pub' % fabric.env.user
491
            _run('mkdir -p ~/.ssh && chmod 700 ~/.ssh', False)
492
            if ssh_keys.startswith("http://") or \
493
                    ssh_keys.startswith("https://") or \
494
                    ssh_keys.startswith("ftp://"):
495
                cmd = """
496
                apt-get update
497
                apt-get install wget --yes --force-yes
498
                wget {0} -O {1} --no-check-certificate
499
                """.format(ssh_keys, keyfile)
500
                _run(cmd, False)
501
            elif os.path.exists(ssh_keys):
502
                _put(ssh_keys, keyfile)
503
            else:
504
                self.logger.debug("No ssh keys found")
505
                return
506
            _run('cat %s >> ~/.ssh/authorized_keys' % keyfile, False)
507
            _run('rm %s' % keyfile, False)
508
            self.logger.debug("Uploaded ssh authorized keys")
509
        else:
510
            self.logger.debug("No ssh keys found")
511

    
512
    def _create_new_build_id(self):
513
        """Find a uniq build_id to use"""
514
        with filelocker.lock("%s.lock" % self.temp_config_file,
515
                             filelocker.LOCK_EX):
516
            # Read temp_config again to get any new entries
517
            self.temp_config.read(self.temp_config_file)
518

    
519
            # Find a uniq build_id to use
520
            if self.build_id is None:
521
                ids = self.temp_config.sections()
522
                if ids:
523
                    max_id = int(max(self.temp_config.sections(), key=int))
524
                    self.build_id = max_id + 1
525
                else:
526
                    self.build_id = 1
527
            self.logger.debug("Will use \"%s\" as build id"
528
                              % _green(self.build_id))
529

    
530
            # Create a new section
531
            try:
532
                self.temp_config.add_section(str(self.build_id))
533
            except DuplicateSectionError:
534
                msg = ("Build id \"%s\" already in use. " +
535
                       "Please use a uniq one or cleanup \"%s\" file.\n") \
536
                    % (self.build_id, self.temp_config_file)
537
                self.logger.error(msg)
538
                sys.exit(1)
539
            creation_time = \
540
                time.strftime("%a, %d %b %Y %X", time.localtime())
541
            self.temp_config.set(str(self.build_id),
542
                                 "created", str(creation_time))
543

    
544
            # Write changes back to temp config file
545
            with open(self.temp_config_file, 'wb') as tcf:
546
                self.temp_config.write(tcf)
547

    
548
    def write_temp_config(self, option, value):
549
        """Write changes back to config file"""
550
        # Acquire the lock to write to temp_config_file
551
        with filelocker.lock("%s.lock" % self.temp_config_file,
552
                             filelocker.LOCK_EX):
553

    
554
            # Read temp_config again to get any new entries
555
            self.temp_config.read(self.temp_config_file)
556

    
557
            self.temp_config.set(str(self.build_id), option, str(value))
558
            curr_time = time.strftime("%a, %d %b %Y %X", time.localtime())
559
            self.temp_config.set(str(self.build_id), "modified", curr_time)
560

    
561
            # Write changes back to temp config file
562
            with open(self.temp_config_file, 'wb') as tcf:
563
                self.temp_config.write(tcf)
564

    
565
    def read_temp_config(self, option):
566
        """Read from temporary_config file"""
567
        # If build_id is None use the latest one
568
        if self.build_id is None:
569
            ids = self.temp_config.sections()
570
            if ids:
571
                self.build_id = int(ids[-1])
572
            else:
573
                self.logger.error("No sections in temporary config file")
574
                sys.exit(1)
575
            self.logger.debug("Will use \"%s\" as build id"
576
                              % _green(self.build_id))
577
        # Read specified option
578
        return self.temp_config.get(str(self.build_id), option)
579

    
580
    def setup_fabric(self):
581
        """Setup fabric environment"""
582
        self.logger.info("Setup fabric parameters..")
583
        fabric.env.user = self.read_temp_config('server_user')
584
        fabric.env.host_string = self.read_temp_config('server_ip')
585
        fabric.env.port = int(self.read_temp_config('server_port'))
586
        fabric.env.password = self.read_temp_config('server_passwd')
587
        fabric.env.connection_attempts = 10
588
        fabric.env.shell = "/bin/bash -c"
589
        fabric.env.disable_known_hosts = True
590
        fabric.env.output_prefix = None
591

    
592
    def _check_hash_sum(self, localfile, remotefile):
593
        """Check hash sums of two files"""
594
        self.logger.debug("Check hash sum for local file %s" % localfile)
595
        hash1 = os.popen("sha256sum %s" % localfile).read().split(' ')[0]
596
        self.logger.debug("Local file has sha256 hash %s" % hash1)
597
        self.logger.debug("Check hash sum for remote file %s" % remotefile)
598
        hash2 = _run("sha256sum %s" % remotefile, False)
599
        hash2 = hash2.split(' ')[0]
600
        self.logger.debug("Remote file has sha256 hash %s" % hash2)
601
        if hash1 != hash2:
602
            self.logger.error("Hashes differ.. aborting")
603
            sys.exit(1)
604

    
605
    @_check_fabric
606
    def clone_repo(self, local_repo=False):
607
        """Clone Synnefo repo from slave server"""
608
        self.logger.info("Configure repositories on remote server..")
609
        self.logger.debug("Install/Setup git")
610
        cmd = """
611
        apt-get install git --yes --force-yes
612
        git config --global user.name {0}
613
        git config --global user.email {1}
614
        """.format(self.config.get('Global', 'git_config_name'),
615
                   self.config.get('Global', 'git_config_mail'))
616
        _run(cmd, False)
617

    
618
        # Clone synnefo_repo
619
        synnefo_branch = self.clone_synnefo_repo(local_repo=local_repo)
620
        # Clone pithos-web-client
621
        self.clone_pithos_webclient_repo(synnefo_branch)
622

    
623
    @_check_fabric
624
    def clone_synnefo_repo(self, local_repo=False):
625
        """Clone Synnefo repo to remote server"""
626
        # Find synnefo_repo and synnefo_branch to use
627
        synnefo_repo = self.config.get('Global', 'synnefo_repo')
628
        synnefo_branch = self.config.get("Global", "synnefo_branch")
629
        if synnefo_branch == "":
630
            synnefo_branch = \
631
                subprocess.Popen(
632
                    ["git", "rev-parse", "--abbrev-ref", "HEAD"],
633
                    stdout=subprocess.PIPE).communicate()[0].strip()
634
            if synnefo_branch == "HEAD":
635
                synnefo_branch = \
636
                    subprocess.Popen(
637
                        ["git", "rev-parse", "--short", "HEAD"],
638
                        stdout=subprocess.PIPE).communicate()[0].strip()
639
        self.logger.debug("Will use branch \"%s\"" % _green(synnefo_branch))
640

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

    
671
        # Checkout the desired synnefo_branch
672
        self.logger.debug("Checkout \"%s\" branch/commit" % synnefo_branch)
673
        cmd = """
674
        cd synnefo
675
        for branch in `git branch -a | grep remotes | grep -v HEAD`; do
676
            git branch --track ${branch##*/} $branch
677
        done
678
        git checkout %s
679
        """ % (synnefo_branch)
680
        _run(cmd, False)
681

    
682
        return synnefo_branch
683

    
684
    @_check_fabric
685
    def clone_pithos_webclient_repo(self, synnefo_branch):
686
        """Clone Pithos WebClient repo to remote server"""
687
        # Find pithos_webclient_repo and pithos_webclient_branch to use
688
        pithos_webclient_repo = \
689
            self.config.get('Global', 'pithos_webclient_repo')
690
        pithos_webclient_branch = \
691
            self.config.get('Global', 'pithos_webclient_branch')
692

    
693
        # Clone pithos-webclient from remote repo
694
        self.logger.debug("Clone pithos-webclient from %s" %
695
                          pithos_webclient_repo)
696
        self._git_clone(pithos_webclient_repo)
697

    
698
        # Track all pithos-webclient branches
699
        cmd = """
700
        cd pithos-web-client
701
        for branch in `git branch -a | grep remotes | grep -v HEAD`; do
702
            git branch --track ${branch##*/} $branch > /dev/null 2>&1
703
        done
704
        git branch
705
        """
706
        webclient_branches = _run(cmd, False)
707
        webclient_branches = webclient_branches.split()
708

    
709
        # If we have pithos_webclient_branch in config file use this one
710
        # else try to use the same branch as synnefo_branch
711
        # else use an appropriate one.
712
        if pithos_webclient_branch == "":
713
            if synnefo_branch in webclient_branches:
714
                pithos_webclient_branch = synnefo_branch
715
            else:
716
                # If synnefo_branch starts with one of
717
                # 'master', 'hotfix'; use the master branch
718
                if synnefo_branch.startswith('master') or \
719
                        synnefo_branch.startswith('hotfix'):
720
                    pithos_webclient_branch = "master"
721
                # If synnefo_branch starts with one of
722
                # 'develop', 'feature'; use the develop branch
723
                elif synnefo_branch.startswith('develop') or \
724
                        synnefo_branch.startswith('feature'):
725
                    pithos_webclient_branch = "develop"
726
                else:
727
                    self.logger.warning(
728
                        "Cannot determine which pithos-web-client branch to "
729
                        "use based on \"%s\" synnefo branch. "
730
                        "Will use develop." % synnefo_branch)
731
                    pithos_webclient_branch = "develop"
732
        # Checkout branch
733
        self.logger.debug("Checkout \"%s\" branch" %
734
                          _green(pithos_webclient_branch))
735
        cmd = """
736
        cd pithos-web-client
737
        git checkout {0}
738
        """.format(pithos_webclient_branch)
739
        _run(cmd, False)
740

    
741
    def _git_clone(self, repo):
742
        """Clone repo to remote server
743

744
        Currently clonning from code.grnet.gr can fail unexpectedly.
745
        So retry!!
746

747
        """
748
        cloned = False
749
        for i in range(1, 11):
750
            try:
751
                _run("git clone %s" % repo, False)
752
                cloned = True
753
                break
754
            except BaseException:
755
                self.logger.warning("Clonning failed.. retrying %s/10" % i)
756
        if not cloned:
757
            self.logger.error("Can not clone repo.")
758
            sys.exit(1)
759

    
760
    @_check_fabric
761
    def build_packages(self):
762
        """Build packages needed by Synnefo software"""
763
        self.logger.info("Install development packages")
764
        kamaki_version = self.config.get('Burnin', 'kamaki_version')
765
        cmd = """
766
        apt-get update
767
        apt-get install zlib1g-dev dpkg-dev debhelper git-buildpackage \
768
                python-dev python-all python-pip ant --yes --force-yes
769
        pip install -U devflow kamaki{0}
770
        """.format(("==" + kamaki_version) if kamaki_version else "")
771
        _run(cmd, False)
772

    
773
        # Patch pydist bug
774
        if self.config.get('Global', 'patch_pydist') == "True":
775
            self.logger.debug("Patch pydist.py module")
776
            cmd = r"""
777
            sed -r -i 's/(\(\?P<name>\[A-Za-z\]\[A-Za-z0-9_\.)/\1\\\-/' \
778
                /usr/share/python/debpython/pydist.py
779
            """
780
            _run(cmd, False)
781

782
        # Build synnefo packages
783
        self.build_synnefo()
784
        # Build pithos-web-client packages
785
        self.build_pithos_webclient()
786

787
    @_check_fabric
788
    def build_synnefo(self):
789
        """Build Synnefo packages"""
790
        self.logger.info("Build Synnefo packages..")
791

792
        cmd = """
793
        devflow-autopkg snapshot -b ~/synnefo_build-area --no-sign
794
        """
795
        with fabric.cd("synnefo"):
796
            _run(cmd, True)
797

798
        # Install snf-deploy package
799
        self.logger.debug("Install snf-deploy package")
800
        cmd = """
801
        dpkg -i snf-deploy*.deb
802
        apt-get -f install --yes --force-yes
803
        """
804
        with fabric.cd("synnefo_build-area"):
805
            with fabric.settings(warn_only=True):
806
                _run(cmd, True)
807

808
        # Setup synnefo packages for snf-deploy
809
        self.logger.debug("Copy synnefo debs to snf-deploy packages dir")
810
        cmd = """
811
        cp ~/synnefo_build-area/*.deb /var/lib/snf-deploy/packages/
812
        """
813
        _run(cmd, False)
814

815
    @_check_fabric
816
    def build_pithos_webclient(self):
817
        """Build pithos-web-client packages"""
818
        self.logger.info("Build pithos-web-client packages..")
819

820
        cmd = """
821
        devflow-autopkg snapshot -b ~/webclient_build-area --no-sign
822
        """
823
        with fabric.cd("pithos-web-client"):
824
            _run(cmd, True)
825

826
        # Setup pithos-web-client packages for snf-deploy
827
        self.logger.debug("Copy webclient debs to snf-deploy packages dir")
828
        cmd = """
829
        cp ~/webclient_build-area/*.deb /var/lib/snf-deploy/packages/
830
        """
831
        _run(cmd, False)
832

833
    @_check_fabric
834
    def build_documentation(self):
835
        """Build Synnefo documentation"""
836
        self.logger.info("Build Synnefo documentation..")
837
        _run("pip install -U Sphinx", False)
838
        with fabric.cd("synnefo"):
839
            _run("devflow-update-version; "
840
                 "./ci/make_docs.sh synnefo_documentation", False)
841

842
    def fetch_documentation(self, dest=None):
843
        """Fetch Synnefo documentation"""
844
        self.logger.info("Fetch Synnefo documentation..")
845
        if dest is None:
846
            dest = "synnefo_documentation"
847
        dest = os.path.abspath(dest)
848
        if not os.path.exists(dest):
849
            os.makedirs(dest)
850
        self.fetch_compressed("synnefo/synnefo_documentation", dest)
851
        self.logger.info("Downloaded documentation to %s" %
852
                         _green(dest))
853

854
    @_check_fabric
855
    def deploy_synnefo(self, schema=None):
856
        """Deploy Synnefo using snf-deploy"""
857
        self.logger.info("Deploy Synnefo..")
858
        if schema is None:
859
            schema = self.config.get('Global', 'schema')
860
        self.logger.debug("Will use \"%s\" schema" % _green(schema))
861

862
        schema_dir = os.path.join(self.ci_dir, "schemas/%s" % schema)
863
        if not (os.path.exists(schema_dir) and os.path.isdir(schema_dir)):
864
            raise ValueError("Unknown schema: %s" % schema)
865

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

869
        self.logger.debug("Change password in nodes.conf file")
870
        cmd = """
871
        sed -i 's/^password =.*/password = {0}/' /etc/snf-deploy/nodes.conf
872
        """.format(fabric.env.password)
873
        _run(cmd, False)
874

875
        self.logger.debug("Run snf-deploy")
876
        cmd = """
877
        snf-deploy keygen --force
878
        snf-deploy --disable-colors --autoconf all
879
        """
880
        _run(cmd, True)
881

882
    @_check_fabric
883
    def unit_test(self):
884
        """Run Synnefo unit test suite"""
885
        self.logger.info("Run Synnefo unit test suite")
886
        component = self.config.get('Unit Tests', 'component')
887

888
        self.logger.debug("Install needed packages")
889
        cmd = """
890
        pip install -U mock
891
        pip install -U factory_boy
892
        pip install -U nose
893
        """
894
        _run(cmd, False)
895

896
        self.logger.debug("Upload tests.sh file")
897
        unit_tests_file = os.path.join(self.ci_dir, "tests.sh")
898
        _put(unit_tests_file, ".")
899

900
        self.logger.debug("Run unit tests")
901
        cmd = """
902
        bash tests.sh {0}
903
        """.format(component)
904
        _run(cmd, True)
905

906
    @_check_fabric
907
    def run_burnin(self):
908
        """Run burnin functional test suite"""
909
        self.logger.info("Run Burnin functional test suite")
910
        cmd = """
911
        auth_url=$(grep -e '^url =' .kamakirc | cut -d' ' -f3)
912
        token=$(grep -e '^token =' .kamakirc | cut -d' ' -f3)
913
        images_user=$(kamaki image list -l | grep owner | \
914
                      cut -d':' -f2 | tr -d ' ')
915
        snf-burnin --auth-url=$auth_url --token=$token {0}
916
        BurninExitStatus=$?
917
        exit $BurninExitStatus
918
        """.format(self.config.get('Burnin', 'cmd_options'))
919
        _run(cmd, True)
920

921
    @_check_fabric
922
    def fetch_compressed(self, src, dest=None):
923
        """Create a tarball and fetch it locally"""
924
        self.logger.debug("Creating tarball of %s" % src)
925
        basename = os.path.basename(src)
926
        tar_file = basename + ".tgz"
927
        cmd = "tar czf %s %s" % (tar_file, src)
928
        _run(cmd, False)
929
        if not os.path.exists(dest):
930
            os.makedirs(dest)
931

932
        tmp_dir = tempfile.mkdtemp()
933
        fabric.get(tar_file, tmp_dir)
934

935
        dest_file = os.path.join(tmp_dir, tar_file)
936
        self._check_hash_sum(dest_file, tar_file)
937
        self.logger.debug("Untar packages file %s" % dest_file)
938
        cmd = """
939
        cd %s
940
        tar xzf %s
941
        cp -r %s/* %s
942
        rm -r %s
943
        """ % (tmp_dir, tar_file, src, dest, tmp_dir)
944
        os.system(cmd)
945
        self.logger.info("Downloaded %s to %s" %
946
                         (src, _green(dest)))
947

948
    @_check_fabric
949
    def fetch_packages(self, dest=None):
950
        """Fetch Synnefo packages"""
951
        if dest is None:
952
            dest = self.config.get('Global', 'pkgs_dir')
953
        dest = os.path.abspath(os.path.expanduser(dest))
954
        if not os.path.exists(dest):
955
            os.makedirs(dest)
956
        self.fetch_compressed("synnefo_build-area", dest)
957
        self.fetch_compressed("webclient_build-area", dest)
958
        self.logger.info("Downloaded debian packages to %s" %
959
                         _green(dest))
960

961
    def x2go_plugin(self, dest=None):
962
        """Produce an html page which will use the x2goplugin
963

    
964
        Arguments:
965
          dest  -- The file where to save the page (String)
966

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

1003
        self.logger.info("Writting x2go plugin html file to %s" % dest)
1004
        fid = open(dest, 'w')
1005
        fid.write(output_str)
1006
        fid.close()
1007

1008

1009
def parse_typed_option(option, value):
1010
    """Parsed typed options (flavors and images)"""
1011
    try:
1012
        [type_, val] = value.strip().split(':')
1013
        if type_ not in ["id", "name"]:
1014
            raise ValueError
1015
        return type_, val
1016
    except ValueError:
1017
        msg = "Invalid %s format. Must be [id|name]:.+" % option
1018
        raise ValueError(msg)
1019