~launchpad-pqm/launchpad/devel

9265.1.1 by Jonathan Lange
Add lp-dev-tools that lack license confusion, changing the license to match
1
#!/usr/bin/python
2
#
3
# Copyright 2009 Canonical Ltd.  This software is licensed under the
4
# GNU Affero General Public License version 3 (see the file LICENSE).
5
6
"""
7
Generate an EC2 image that is capable of running the Windmill browser UI
8
testing tool.
9
10
You must provide a base image that will be augmented with the necessary
11
packages and configuration.
12
13
The script requires certain options to be specified in order to function
14
properly.  These options may be supplied using command-line switches, or
15
via a config file, with the --config command-line switch.  The default
16
config file location is ~/.ec2/ec2bundle.cfg
17
18
The config file format simply replicates the required command-line options
19
as configuration keys.
20
21
---- ec2bundle.cfg ---
22
23
[DEFAULT]
24
key = gsg-keypair
25
identity-file = ~/.ec2/foo-keypair-id_rsa
26
private-key = ~/.ec2/pk-HKZYKTAIG2ECMXYIBH3HXV4ZBZQ55CLO.pem
27
cert =~/.ec2/cert-HKZYKTAIG2ECMXYIBH3HXV4ZBZQ55CLO.pem
28
user-id = AIDADH4IGTRXXKCD
29
access-key = SOMEBIGSTRINGOFDIGITS
30
secret-key = s0m3funKyStr1Ng0fD1gitZ
31
#bucket = foo  # Required, but you probably want to customize it each time.
32
33
---- fin ---
34
35
"""
36
37
__metatype__ = type
38
39
40
# Reuse a whole bunch of code from ec2test.py.
41
import ConfigParser
42
import ec2test
43
import logging
44
import optparse
45
import os
46
import paramiko
47
import select
48
import socket
49
import subprocess
50
import sys
51
import time
52
53
54
log   = logging.getLogger(__name__)
55
info  = log.info
56
debug = log.debug
57
58
59
usage = """
60
Generate an EC2 image for Windmill testing in Firefox.
61
62
usage: %prog [options] AMI-ID
63
"""
64
65
class Instance:
66
    """An EC2 instance controller."""
67
68
    def __init__(self, instance):
69
        self._instance = instance
70
71
    @property
72
    def id(self):
73
        return self._instance.id
74
75
    @property
76
    def hostname(self):
77
        return self._instance.public_dns_name
78
79
    def stop(self):
80
        instance = self._instance
81
82
        instance.update()
83
        if instance.state not in ('shutting-down', 'terminated'):
84
            # terminate instance
85
            instance.stop()
86
            instance.update()
87
88
        info('instance %s\n' % (instance.state,))
89
90
    def wait_for_instance_to_start(self):
91
        """Wait for the instance to transition to the "running" state."""
92
        instance = self._instance
93
        info('Instance %s starting..' % instance.id)
94
95
        start = time.time()
96
        while instance.state == 'pending':
97
            sys.stdout.write('.')
98
            sys.stdout.flush()
99
            time.sleep(5)
100
            instance.update()
101
        if instance.state == 'running':
102
            info('\ninstance now running at %s\n' % instance.public_dns_name)
103
            elapsed = time.time() - start
104
            info('Started in %d minutes %d seconds\n' %
105
                     (elapsed // 60, elapsed % 60))
106
            cout = instance.get_console_output()
107
            info(cout.output)
108
        else:
109
            raise RuntimeError('failed to start: %s\n' % instance.state)
110
111
    @classmethod
112
    def from_image(cls, account, ami_id, instance_type):
113
        """Return a new instance using the given startup parameters."""
114
        info("Starting instance")
115
116
        # Set up a security group that opens up ports 22, 80, and 443.  Also
117
        # opens up access for our IP.
118
        account.acquire_security_group()
119
120
        image = account.acquire_image(ami_id)
121
        key = account.name
122
        debug("Image: %s, Type: %s, Key: %s" % (
123
              ami_id, instance_type, key))
124
125
        reservation = image.run(
126
            key_name=key,
127
            security_groups=[key],
128
            instance_type=instance_type)
129
130
        instance = cls(reservation.instances[0])
131
        instance.wait_for_instance_to_start()
132
        return instance
133
134
    @classmethod
135
    def from_running_instance(cls, account, instance_id):
136
        """Create an object from an already running EC2 instance."""
137
        instance = account.get_instance(instance_id)
138
        if not instance:
139
            raise RuntimeError(
140
                "Unable to connect to instance %s" % instance_id)
141
142
        info("Connected to instance %s" % instance_id)
143
        proxy = cls(instance)
144
        # Just to be extra safe.
145
        proxy.wait_for_instance_to_start()
146
        return proxy
147
148
149
class SSHConnector:
150
    """Handle the various aspects of using an SSH connection."""
151
152
    def __init__(self, hostname, user, identity_file):
153
        self.hostname = hostname
154
        self.user = user
155
        self.identity_file = os.path.expanduser(identity_file)
156
        self._client = None
157
158
    def get_private_key(self):
159
        """Generate a private key object for our keyfile"""
160
        fp = os.path.expanduser(self.identity_file)
161
        return paramiko.RSAKey.from_private_key(open(fp))
162
163
    def connect(self):
164
        info('Waiting for SSH to come available: %s@%s\n' % (
165
             self.user, self.hostname))
166
        debug("Using private key file: %s" % self.identity_file)
167
168
        private_key = self.get_private_key()
169
170
        for count in range(10):
171
            self._client = paramiko.SSHClient()
172
            self._client.set_missing_host_key_policy(ec2test.AcceptAllPolicy())
173
174
            try:
175
176
                self._client.connect(
177
                    self.hostname,
178
                    username=self.user,
179
                    pkey=private_key,
180
                    allow_agent=False,
181
                    look_for_keys=False)
182
183
            except (socket.error, paramiko.AuthenticationException), e:
184
                log.warning('wait_for_connection: %r' % (e,))
185
                if count < 9:
186
                    time.sleep(5)
187
                    info('retrying...')
188
                else:
189
                    raise
190
            else:
191
                break
192
193
    def exec_command(self, remote_command, check_return=True):
194
        """Execute a command on the remote server.
195
196
        Raises an error if the command returns an exit status that is not
197
        zero, unless the option `check_return=False' has been given.
198
        """
199
        info('Executing command: %s@%s %s\n' % (self.user, self.hostname, remote_command))
200
201
        session = self._client.get_transport().open_session()
202
        session.exec_command(remote_command)
203
        session.shutdown_write()
204
205
        # TODO: change this to use the logging module
206
        while True:
207
            select.select([session], [], [], 0.5)
208
            if session.recv_ready():
209
                data = session.recv(4096)
210
                if data:
211
                    sys.stdout.write(data)
212
                    sys.stdout.flush()
213
            if session.recv_stderr_ready():
214
                data = session.recv_stderr(4096)
215
                if data:
216
                    sys.stderr.write(data)
217
                    sys.stderr.flush()
218
            if session.exit_status_ready():
219
                break
220
        session.close()
221
222
        # XXX: JonathanLange 2009-05-31: If the command is killed by a signal
223
        # on the remote server, the SSH protocol does not send an exit_status,
224
        # it instead sends a different message with the number of the signal
225
        # that killed the process. AIUI, this code will fail confusingly if
226
        # that happens.
227
        exit_status = session.recv_exit_status()
228
        if exit_status and check_return:
229
            raise RuntimeError('Command failed: %s' % (remote_command,))
230
        return exit_status
231
232
    def copy_to_remote(self, local_filename, remote_filename):
233
        cmd = [
234
            'scp',
235
            '-i', self.identity_file,
236
            local_filename,
237
            '%s@%s:%s' % (self.user, self.hostname, remote_filename)
238
            ]
239
        info("Executing command: %s" % ' '.join(cmd))
240
        subprocess.check_call(cmd)
241
242
    def user_command(self):
243
        """Return a user-friendly ssh command-line string."""
244
        return "ssh -i %s %s@%s" % (
245
            self.identity_file,
246
            self.user,
247
            self.hostname)
248
249
250
class ImageBundler:
251
    """Bundle an EC2 image on a remote system."""
252
253
    def __init__(self, private_key, cert, account_id, target_bucket,
254
                 access_key, secret_key, ssh):
255
        self.private_key = os.path.expanduser(private_key)
256
        self.cert = os.path.expanduser(cert)
257
        self.account_id = account_id
258
        self.target_bucket = target_bucket
259
        self.access_key = access_key
260
        self.secret_key = secret_key
261
        self.ssh = ssh
262
263
        # Use the instance /mnt directory by default, because it has a few
264
        # hundred GB of free space to work with.
265
        self._bundle_dir = os.path.join('/mnt', target_bucket)
266
267
    def bundle_image(self):
268
        self.configure_bundling_environment()
269
        manifest = self._bundle_image()
270
        self._upload_bundle(manifest)
271
        self._register_image(manifest)
272
273
    def remote_private_keypath(self):
274
        # ALWAYS have these files in /mnt on the remote system.  Otherwise
275
        # they will get bundled along with the image.
276
        return os.path.join('/mnt', os.path.basename(self.private_key))
277
278
    def remote_certpath(self):
279
        # ALWAYS have these files in /mnt on the remote system.  Otherwise
280
        # they will get bundled along with the image.
281
        return os.path.join('/mnt', os.path.basename(self.cert))
282
283
    def configure_bundling_environment(self):
284
        """Configure what we need on the instance for bundling the image."""
285
        # Send our keypair to the remote environment so that it can be used
286
        # to bundle the image.
287
        local_cert = os.path.abspath(self.cert)
288
        local_pkey = os.path.abspath(self.private_key)
289
290
        # ALWAYS copy these files into /mnt on the remote system.  Otherwise
291
        # they will get bundled along with the image.
292
        remote_cert = self.remote_certpath()
293
        remote_pkey = self.remote_private_keypath()
294
295
        # See if the files are present, and copy them over if they are not.
296
        self._ensure_remote_file(remote_cert, local_cert)
297
        self._ensure_remote_file(remote_pkey, local_pkey)
298
299
    def _ensure_remote_file(self, remote_file, desired_file):
300
        info("Checking for '%s' on the remote system" % remote_file)
301
        test = 'test -f %s' % remote_file
302
        exit_status = self.ssh.exec_command(test, check_return=False)
303
        if bool(exit_status):
304
            self.ssh.copy_to_remote(desired_file, remote_file)
305
306
    def _bundle_image(self):
307
        # Create the bundle in a subdirectory, to avoid spamming up /mnt.
308
        self.ssh.exec_command(
309
            'mkdir %s' % self._bundle_dir, check_return=False)
310
311
        cmd = [
312
            'ec2-bundle-vol',
313
            '-d %s' % self._bundle_dir,
314
            '-b',   # Set batch-mode, which doesn't use prompts.
315
            '-k %s' % self.remote_private_keypath(),
316
            '-c %s' % self.remote_certpath(),
317
            '-u %s'  % self.account_id
318
            ]
319
320
        self.ssh.exec_command(' '.join(cmd))
321
        # Assume that the manifest is 'image.manifest.xml', since "image" is
322
        # the default prefix.
323
        manifest = os.path.join(self._bundle_dir, 'image.manifest.xml')
324
325
        # Best check that the manifest actually exists though.
326
        test = 'test -f %s' % manifest
327
        exit_status = self.ssh.exec_command(test, check_return=False)
328
329
        if bool(exit_status):
330
            raise RuntimeError(
331
                "Failed to write the image manifest file: %s" % manifest)
332
333
        return manifest
334
335
    def _upload_bundle(self, manifest):
336
        cmd = [
337
            'ec2-upload-bundle',
338
            '-b %s' % self.target_bucket,
339
            '-m %s' % manifest,
340
            '-a %s' % self.access_key,
341
            '-s %s' % self.secret_key
342
            ]
343
        self.ssh.exec_command(' '.join(cmd))
344
345
    def _register_image(self, manifest):
346
        # This is invoked locally.
347
        mfilename = os.path.basename(manifest)
348
        manifest_path = os.path.join(self.target_bucket, mfilename)
349
350
        env = os.environ.copy()
351
        env['JAVA_HOME'] = '/usr/lib/jvm/default-java'
352
        cmd = [
353
            'ec2-register',
354
            '--private-key=%s' % self.private_key,
355
            '--cert=%s' % self.cert,
356
            manifest_path
357
            ]
358
        info("Executing command: %s" % ' '.join(cmd))
359
        subprocess.check_call(cmd, env=env)
360
361
362
class XvfbSystemConfigurator:
363
    """Configure a remote operating system over SSH to use the xvfb server."""
364
365
    def __init__(self, ssh):
366
        self.ssh = ssh
367
368
    def configure_system(self):
369
        """Configure the operating system with the needed packages, etc."""
370
        do = self.ssh.exec_command
371
372
        # Make sure we know about all the packages, and where they may be
373
        # found.
374
        do("apt-get -y update")
375
        # Install the necessary packages
376
        do("apt-get -y install xvfb firefox xfonts-base")
377
378
379
class CombinedConfigParser:
380
    """Store and reconcile options for both optparse and ConfigParser."""
381
382
    def __init__(self, optparser, cfgparser):
383
        self._optparser = optparser
384
        self._cfgparser = cfgparser
385
386
        # A list of required optparse options.
387
        self.required_options = []
388
        self.known_options = []
389
390
        # Our parsed positional command-line arguments, as returned by
391
        # optparse.OptionParser.parse_args()
392
        self.args = None
393
394
        # An optparse option.dest to 'cfg-key' mapping.
395
        self._option_to_cfgkey = {}
396
397
        self._parsed_cli_options = None
398
        self._parsed_cfg_options = None
399
400
    def __getattr__(self, name):
401
        return self.get(name)
402
403
    def add_option(self, *args, **kwds):
404
        """Wrap the OptionParser.add_option() method, and add our options."""
405
        try:
406
            # We can't pass unknown kwds to make_option, or it will barf.
407
            is_required = kwds.pop('required')
408
        except KeyError:
409
            is_required = False
410
411
        option = optparse.make_option(*args, **kwds)
412
        self._optparser.add_option(option)
413
414
        if is_required:
415
            self.add_required_option(option)
416
417
        self._add_option_to_cfg_mapping(option, args)
418
419
    def add_required_option(self, option):
420
        """Add a required option.
421
422
        Takes an optparse.Option object.
423
        """
424
        self.required_options.append(option)
425
426
    def _add_option_to_cfg_mapping(self, option, option_constructor_args):
427
        # Convert the long options into .ini keys.  Use the last long option
428
        # given.
429
        for switch in reversed(option_constructor_args):
430
            if switch.startswith('--'):
431
                # We found a '--foo' switch, so use it.  Drop the '--',
432
                # because the config file doesn't use the prefixes.
433
                self._option_to_cfgkey[option.dest] = switch[2:]
434
435
    def error(self, message):
436
        """Wrap optparse.OptionParser.error()."""
437
        self._optparser.error(message)
438
439
    def parse_config_file(self, filepath):
440
        fp = os.path.expanduser(filepath)
441
442
        if not os.path.exists(fp):
443
            self.error("The config file '%s' does not exist!" % fp)
444
445
        self._cfgparser.read(fp)
446
        self._parsed_cfg_options = self._cfgparser.defaults()
447
448
        num_opts = len(self._parsed_cfg_options)
449
        debug("Loaded %d options from %s" % (num_opts, fp))
450
451
    def parse_cli_args(self, argv):
452
        """Wrap optparse.OptionParser.parse_args()."""
453
        options, args = self._optparser.parse_args(argv)
454
        self._parsed_cli_options = options
455
        self.args = args
456
        return (options, args)
457
458
    def verify_options(self):
459
        """Verify that all required options are there.
460
461
        Raise an optparse.OptionParser.error() if something is missing.
462
463
        Make sure you parsed the config file with parse_config_file() before
464
        doing this.
465
        """
466
        debug("Verifying options")
467
        if not self._parsed_cfg_options:
468
            debug("No config file options found")
469
470
        for option in self.required_options:
471
            # Check for a command-line option.
472
473
            option_name = option.dest
474
475
            if self.get(option_name) is None:
476
                self._required_option_error(option)
477
            else:
478
                debug("Found required option: %s" % option_name)
479
480
    def _required_option_error(self, option):
481
        msg = "Required option '%s' was not given (-h for help)" % str(option)
482
        self.error(msg)
483
484
    def get(self, name, default=None):
485
        """Return the appropriate option, CLI first, CFG second."""
486
        cli_name = name
487
        cfg_name = self._option_to_cfgkey.get(name)
488
489
        value = self._getoption(cli_name)
490
491
        if value is None and cfg_name is not None:
492
            # No command-line option was supplied, but we do have a config
493
            # file entry with that name.
494
            value = self._getcfg(cfg_name)
495
496
            if value is None:
497
                # No config file option was supplied either, so return the
498
                # default.
499
                return default
500
501
        return value
502
503
    def _getoption(self, key, default=None):
504
        return getattr(self._parsed_cli_options, key, default)
505
506
    def _getcfg(self, key, default=None):
507
        return self._parsed_cfg_options.get(key, default)
508
509
510
def get_credentials():
511
    """Return an EC2Credentials object for accessing the webservice."""
512
    # Get the AWS identifier and secret identifier.
513
    return ec2test.EC2Credentials.load_from_file()
514
515
516
def parse_config_file(filepath):
517
    config = ConfigParser.ConfigParser()
518
    config.read(filepath)
519
    return config
520
521
522
def parse_options(argv):
523
    oparser = optparse.OptionParser(usage)
524
    cparser = ConfigParser.SafeConfigParser()
525
    parser = CombinedConfigParser(oparser, cparser)
526
527
    # What follows are "Required options" - these must be supplied from either
528
    # the command-line, or from a config file.
529
    parser.add_option(
530
        '-k', '--key',
531
        dest="keypair_name",
532
        required=True,
533
        help="The name of the AWS key pair to use for launching instances.")
534
535
    parser.add_option(
536
        '-K', '--private-key',
537
        dest="private_key",
538
        required=True,
539
        help="The X.509 private keyfile that will be used to sign the new "
540
             "image.")
541
542
    parser.add_option(
543
        '-C', '--cert',
544
        dest="cert",
545
        required=True,
546
        help="The X.509 certificate that will be used to bundle the new "
547
             "image.")
548
549
    parser.add_option(
550
        '-i', '--identity-file',
551
        dest='identity_file',
552
        required=True,
553
        help="The location of the RSA private key that SSH will use to "
554
             "connect to the instance.")
555
556
    parser.add_option(
557
        '-b', '--bucket',
558
        dest="bucket",
559
        required=True,
560
        help="The bucket that the image will be placed into.")
561
562
    parser.add_option(
563
        '-u', '--user-id',
564
        dest="account_id",
565
        required=True,
566
        help="Your 12 digit AWS account ID")
567
568
    parser.add_option(
569
        '-a', '--access-key',
570
        dest="access_key",
571
        required=True,
572
        help="Your AWS access key.")
573
574
    parser.add_option(
575
        '-s', '--secret-key',
576
        dest="secret_key",
577
        required=True,
578
        help="Your AWS secret key.")
579
580
581
    # Start our "Optional options."
582
    parser.add_option(
583
        '-v', '--verbose',
584
        action='store_true',
585
        dest='verbose',
586
        default=False,
587
        help="Turn on debug output.")
588
589
    parser.add_option(
590
        '-t', '--instance-type',
591
        dest="instance_type",
592
        default="m1.large",
593
        help="The type of instance to be launched.  Should be the same as "
594
             "the base image's required type. [default: %default]")
595
596
    parser.add_option(
597
        '-c', '--config',
598
        dest='config',
599
        default="~/.ec2/ec2bundle.cfg",
600
        help="Load script options from the supplied config file. (.ini "
601
             "format, see the module docstring for details.) "
602
             "[default: %default]")
603
604
    parser.add_option(
605
        '--keepalive',
606
        action='store_true',
607
        dest="keepalive",
608
        default=False,
609
        help="Don't shut down the instance when we are done building (or "
610
             "erroring out).")
611
612
    parser.add_option(
613
        '--no-bundle',
614
        action='store_true',
615
        dest='no_bundle',
616
        default=False,
617
        help="Don't create a bundle, just start the server and configure the "
618
             "environment.")
619
620
    parser.add_option(
621
        '--use-instance',
622
        dest='running_instance',
623
        help="Use the supplied EC2 instance ID, instead of starting our own "
624
             "server.  The instance will be left running.")
625
626
627
    options, args = parser.parse_cli_args(argv)
628
629
    # Do this ASAP
630
    if options.verbose:
631
        log.setLevel(logging.DEBUG)
632
633
    if options.config:
634
        parser.parse_config_file(options.config)
635
636
    # Make sure all the required args are present.  Will error-out if
637
    # something is missing.
638
    parser.verify_options()
639
640
    if len(args) != 2:
641
        parser.error("You must provide an AMI ID that can serve as the new "
642
                     "image's base.")
643
644
    return parser
645
646
647
def main(argv):
648
    config = parse_options(argv)
649
650
    credentials = get_credentials()
651
    account = credentials.connect(config.keypair_name)
652
653
    # Save the flag so we can change it.  This is how we enforce shutdown
654
    # policies.
655
    keepalive = config.keepalive
656
657
    if config.running_instance:
658
        # We want to keep the server alive if the user supplied their own
659
        # instance.  Killing it without their consent would be cruel.
660
        keepalive = True
661
662
    ssh_user_command = None
663
    try:
664
        try:
665
            instance = None
666
            if config.running_instance:
667
                # Connect to an already running instance.
668
                instance = Instance.from_running_instance(
669
                    account, config.running_instance)
670
            else:
671
                # Run an instance for our base image.
672
                instance = Instance.from_image(
673
                    account, config.args[1], config.instance_type)
674
675
            ssh = SSHConnector(
676
                instance.hostname, 'root', config.identity_file)
677
            ssh.connect()
678
            ssh_user_command = ssh.user_command()
679
680
            system_configurator = XvfbSystemConfigurator(ssh)
681
            system_configurator.configure_system()
682
683
            if not config.no_bundle:
684
                bundler = ImageBundler(
685
                    config.private_key,
686
                    config.cert,
687
                    config.account_id,
688
                    config.bucket,
689
                    config.access_key,
690
                    config.secret_key,
691
                    ssh)
692
                bundler.bundle_image()
693
694
        except:
695
            # Log the exception now so it doesn't interfere with or get eaten
696
            # by the instance shutdown.
697
            log.exception("Oops!")
698
    finally:
699
        if keepalive:
700
            log.warning("instance %s is now running on its own" % instance.id)
701
            if ssh_user_command:
702
                info("You may now ssh into the instance using the following command:")
703
                info("  $ %s" % ssh_user_command)
704
705
            log.warning("Remember to shut the instance down when you are done!")
706
        else:
707
            instance.stop()
708
709
710
if __name__ == '__main__':
711
    logging.basicConfig()
712
    main(sys.argv)