Statistics
| Branch: | Tag: | Revision:

root / ci / utils.py @ ee6eff28

History | View | Annotate | Download (40 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, parse_endpoints
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
        endpoints = self.astakos_client.authenticate()
204

    
205
        cyclades_url = get_endpoint_url(endpoints, "compute")
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 = get_endpoint_url(endpoints, "network")
211
        self.logger.debug("Network API url is %s" % _green(network_url))
212
        self.network_client = CycladesNetworkClient(network_url, token)
213
        self.network_client.CONNECTION_RETRY_LIMIT = 2
214

    
215
        image_url = get_endpoint_url(endpoints, "image")
216
        self.logger.debug("Images API url is %s" % _green(image_url))
217
        self.image_client = ImageClient(cyclades_url, token)
218
        self.image_client.CONNECTION_RETRY_LIMIT = 2
219

    
220
        compute_url = get_endpoint_url(endpoints, "compute")
221
        self.logger.debug("Compute API url is %s" % _green(compute_url))
222
        self.compute_client = ComputeClient(compute_url, token)
223
        self.compute_client.CONNECTION_RETRY_LIMIT = 2
224

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

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

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

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

    
284
    @_check_kamaki
285
    # Too many local variables. pylint: disable-msg=R0914
286
    def create_server(self, image=None, flavor=None, ssh_keys=None,
287
                      server_name=None):
288
        """Create slave server"""
289
        self.logger.info("Create a new server..")
290

    
291
        # Find a build_id to use
292
        self._create_new_build_id()
293

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    
705
        return synnefo_branch
706

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

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

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

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

    
764
    def _git_clone(self, repo):
765
        """Clone repo to remote server
766

767
        Currently clonning from code.grnet.gr can fail unexpectedly.
768
        So retry!!
769

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

954
        tmp_dir = tempfile.mkdtemp()
955
        fabric.get(tar_file, tmp_dir)
956

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

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

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

    
986
        Arguments:
987
          dest  -- The file where to save the page (String)
988

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

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

1030

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

1042

1043
def get_endpoint_url(endpoints, endpoint_type):
1044
    """Get the publicURL for the specified endpoint"""
1045

1046
    service_catalog = parse_endpoints(endpoints, ep_type=endpoint_type)
1047
    return service_catalog[0]['endpoints'][0]['publicURL']
1048