3
# Copyright 2009 Canonical Ltd. This software is licensed under the
4
# GNU Affero General Public License version 3 (see the file LICENSE).
7
Generate an EC2 image that is capable of running the Windmill browser UI
10
You must provide a base image that will be augmented with the necessary
11
packages and configuration.
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
18
The config file format simply replicates the required command-line options
19
as configuration keys.
21
---- ec2bundle.cfg ---
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.
40
# Reuse a whole bunch of code from ec2test.py.
54
log = logging.getLogger(__name__)
60
Generate an EC2 image for Windmill testing in Firefox.
62
usage: %prog [options] AMI-ID
66
"""An EC2 instance controller."""
68
def __init__(self, instance):
69
self._instance = instance
73
return self._instance.id
77
return self._instance.public_dns_name
80
instance = self._instance
83
if instance.state not in ('shutting-down', 'terminated'):
88
info('instance %s\n' % (instance.state,))
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)
96
while instance.state == 'pending':
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()
109
raise RuntimeError('failed to start: %s\n' % instance.state)
112
def from_image(cls, account, ami_id, instance_type):
113
"""Return a new instance using the given startup parameters."""
114
info("Starting instance")
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()
120
image = account.acquire_image(ami_id)
122
debug("Image: %s, Type: %s, Key: %s" % (
123
ami_id, instance_type, key))
125
reservation = image.run(
127
security_groups=[key],
128
instance_type=instance_type)
130
instance = cls(reservation.instances[0])
131
instance.wait_for_instance_to_start()
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)
140
"Unable to connect to instance %s" % instance_id)
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()
150
"""Handle the various aspects of using an SSH connection."""
152
def __init__(self, hostname, user, identity_file):
153
self.hostname = hostname
155
self.identity_file = os.path.expanduser(identity_file)
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))
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)
168
private_key = self.get_private_key()
170
for count in range(10):
171
self._client = paramiko.SSHClient()
172
self._client.set_missing_host_key_policy(ec2test.AcceptAllPolicy())
176
self._client.connect(
183
except (socket.error, paramiko.AuthenticationException), e:
184
log.warning('wait_for_connection: %r' % (e,))
193
def exec_command(self, remote_command, check_return=True):
194
"""Execute a command on the remote server.
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.
199
info('Executing command: %s@%s %s\n' % (self.user, self.hostname, remote_command))
201
session = self._client.get_transport().open_session()
202
session.exec_command(remote_command)
203
session.shutdown_write()
205
# TODO: change this to use the logging module
207
select.select([session], [], [], 0.5)
208
if session.recv_ready():
209
data = session.recv(4096)
211
sys.stdout.write(data)
213
if session.recv_stderr_ready():
214
data = session.recv_stderr(4096)
216
sys.stderr.write(data)
218
if session.exit_status_ready():
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
227
exit_status = session.recv_exit_status()
228
if exit_status and check_return:
229
raise RuntimeError('Command failed: %s' % (remote_command,))
232
def copy_to_remote(self, local_filename, remote_filename):
235
'-i', self.identity_file,
237
'%s@%s:%s' % (self.user, self.hostname, remote_filename)
239
info("Executing command: %s" % ' '.join(cmd))
240
subprocess.check_call(cmd)
242
def user_command(self):
243
"""Return a user-friendly ssh command-line string."""
244
return "ssh -i %s %s@%s" % (
251
"""Bundle an EC2 image on a remote system."""
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
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)
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)
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))
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))
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)
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()
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)
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)
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)
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
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')
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)
329
if bool(exit_status):
331
"Failed to write the image manifest file: %s" % manifest)
335
def _upload_bundle(self, manifest):
338
'-b %s' % self.target_bucket,
340
'-a %s' % self.access_key,
341
'-s %s' % self.secret_key
343
self.ssh.exec_command(' '.join(cmd))
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)
350
env = os.environ.copy()
351
env['JAVA_HOME'] = '/usr/lib/jvm/default-java'
354
'--private-key=%s' % self.private_key,
355
'--cert=%s' % self.cert,
358
info("Executing command: %s" % ' '.join(cmd))
359
subprocess.check_call(cmd, env=env)
362
class XvfbSystemConfigurator:
363
"""Configure a remote operating system over SSH to use the xvfb server."""
365
def __init__(self, ssh):
368
def configure_system(self):
369
"""Configure the operating system with the needed packages, etc."""
370
do = self.ssh.exec_command
372
# Make sure we know about all the packages, and where they may be
374
do("apt-get -y update")
375
# Install the necessary packages
376
do("apt-get -y install xvfb firefox xfonts-base")
379
class CombinedConfigParser:
380
"""Store and reconcile options for both optparse and ConfigParser."""
382
def __init__(self, optparser, cfgparser):
383
self._optparser = optparser
384
self._cfgparser = cfgparser
386
# A list of required optparse options.
387
self.required_options = []
388
self.known_options = []
390
# Our parsed positional command-line arguments, as returned by
391
# optparse.OptionParser.parse_args()
394
# An optparse option.dest to 'cfg-key' mapping.
395
self._option_to_cfgkey = {}
397
self._parsed_cli_options = None
398
self._parsed_cfg_options = None
400
def __getattr__(self, name):
401
return self.get(name)
403
def add_option(self, *args, **kwds):
404
"""Wrap the OptionParser.add_option() method, and add our options."""
406
# We can't pass unknown kwds to make_option, or it will barf.
407
is_required = kwds.pop('required')
411
option = optparse.make_option(*args, **kwds)
412
self._optparser.add_option(option)
415
self.add_required_option(option)
417
self._add_option_to_cfg_mapping(option, args)
419
def add_required_option(self, option):
420
"""Add a required option.
422
Takes an optparse.Option object.
424
self.required_options.append(option)
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
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:]
435
def error(self, message):
436
"""Wrap optparse.OptionParser.error()."""
437
self._optparser.error(message)
439
def parse_config_file(self, filepath):
440
fp = os.path.expanduser(filepath)
442
if not os.path.exists(fp):
443
self.error("The config file '%s' does not exist!" % fp)
445
self._cfgparser.read(fp)
446
self._parsed_cfg_options = self._cfgparser.defaults()
448
num_opts = len(self._parsed_cfg_options)
449
debug("Loaded %d options from %s" % (num_opts, fp))
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
456
return (options, args)
458
def verify_options(self):
459
"""Verify that all required options are there.
461
Raise an optparse.OptionParser.error() if something is missing.
463
Make sure you parsed the config file with parse_config_file() before
466
debug("Verifying options")
467
if not self._parsed_cfg_options:
468
debug("No config file options found")
470
for option in self.required_options:
471
# Check for a command-line option.
473
option_name = option.dest
475
if self.get(option_name) is None:
476
self._required_option_error(option)
478
debug("Found required option: %s" % option_name)
480
def _required_option_error(self, option):
481
msg = "Required option '%s' was not given (-h for help)" % str(option)
484
def get(self, name, default=None):
485
"""Return the appropriate option, CLI first, CFG second."""
487
cfg_name = self._option_to_cfgkey.get(name)
489
value = self._getoption(cli_name)
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)
497
# No config file option was supplied either, so return the
503
def _getoption(self, key, default=None):
504
return getattr(self._parsed_cli_options, key, default)
506
def _getcfg(self, key, default=None):
507
return self._parsed_cfg_options.get(key, default)
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()
516
def parse_config_file(filepath):
517
config = ConfigParser.ConfigParser()
518
config.read(filepath)
522
def parse_options(argv):
523
oparser = optparse.OptionParser(usage)
524
cparser = ConfigParser.SafeConfigParser()
525
parser = CombinedConfigParser(oparser, cparser)
527
# What follows are "Required options" - these must be supplied from either
528
# the command-line, or from a config file.
533
help="The name of the AWS key pair to use for launching instances.")
536
'-K', '--private-key',
539
help="The X.509 private keyfile that will be used to sign the new "
546
help="The X.509 certificate that will be used to bundle the new "
550
'-i', '--identity-file',
551
dest='identity_file',
553
help="The location of the RSA private key that SSH will use to "
554
"connect to the instance.")
560
help="The bucket that the image will be placed into.")
566
help="Your 12 digit AWS account ID")
569
'-a', '--access-key',
572
help="Your AWS access key.")
575
'-s', '--secret-key',
578
help="Your AWS secret key.")
581
# Start our "Optional options."
587
help="Turn on debug output.")
590
'-t', '--instance-type',
591
dest="instance_type",
593
help="The type of instance to be launched. Should be the same as "
594
"the base image's required type. [default: %default]")
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]")
609
help="Don't shut down the instance when we are done building (or "
617
help="Don't create a bundle, just start the server and configure the "
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.")
627
options, args = parser.parse_cli_args(argv)
631
log.setLevel(logging.DEBUG)
634
parser.parse_config_file(options.config)
636
# Make sure all the required args are present. Will error-out if
637
# something is missing.
638
parser.verify_options()
641
parser.error("You must provide an AMI ID that can serve as the new "
648
config = parse_options(argv)
650
credentials = get_credentials()
651
account = credentials.connect(config.keypair_name)
653
# Save the flag so we can change it. This is how we enforce shutdown
655
keepalive = config.keepalive
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.
662
ssh_user_command = None
666
if config.running_instance:
667
# Connect to an already running instance.
668
instance = Instance.from_running_instance(
669
account, config.running_instance)
671
# Run an instance for our base image.
672
instance = Instance.from_image(
673
account, config.args[1], config.instance_type)
676
instance.hostname, 'root', config.identity_file)
678
ssh_user_command = ssh.user_command()
680
system_configurator = XvfbSystemConfigurator(ssh)
681
system_configurator.configure_system()
683
if not config.no_bundle:
684
bundler = ImageBundler(
692
bundler.bundle_image()
695
# Log the exception now so it doesn't interfere with or get eaten
696
# by the instance shutdown.
697
log.exception("Oops!")
700
log.warning("instance %s is now running on its own" % instance.id)
702
info("You may now ssh into the instance using the following command:")
703
info(" $ %s" % ssh_user_command)
705
log.warning("Remember to shut the instance down when you are done!")
710
if __name__ == '__main__':
711
logging.basicConfig()