Merge lp:~allenap/launchpad/ec2-test-race-bug-422433 into lp:launchpad

Proposed by Gavin Panella
Status: Merged
Merged at revision: not available
Proposed branch: lp:~allenap/launchpad/ec2-test-race-bug-422433
Merge into: lp:launchpad
Diff against target: 717 lines
8 files modified
lib/devscripts/ec2test/account.py (+58/-48)
lib/devscripts/ec2test/builtins.py (+40/-35)
lib/devscripts/ec2test/instance.py (+26/-18)
lib/devscripts/ec2test/session.py (+90/-0)
lib/devscripts/ec2test/tests/__init__.py (+2/-0)
lib/devscripts/ec2test/tests/test_session.py (+69/-0)
lib/devscripts/ec2test/tests/test_utils.py (+61/-0)
lib/devscripts/ec2test/utils.py (+55/-0)
To merge this branch: bzr merge lp:~allenap/launchpad/ec2-test-race-bug-422433
Reviewer Review Type Date Requested Status
Abel Deuring (community) Approve
Review via email: mp+12778@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :
Download full text (3.4 KiB)

This branch attempts to resolve a race condition when starting two or
more ec2test instances at similar times. What can happen is that,
during setup, one session can delete the key pair for the other
session, or delete the security group that the other session has just
created, because a static name ("ec2-test-runner") is used to name
these objects within AWS.

Now, with this branch, a session-specific name is used to name key
pairs and security groups so that sessions don't clobber each
other. The name contains some random data to give a good guarantee of
uniqueness.

However, one useful feature of using a static name is that old stuff
gets cleared up, so the new session name also contains a expiry
timestamp after which time the object can be deleted. The methods
delete_previous_security_groups() and delete_previous_key_pairs()
respect this timestamp.

I cleaned up a big pile of lint in revision 9611, and it's basically
noise next to this feature (though it still needs review). I've
prepared two diffs to help review, one containing only the lint and
another with only the feature changes.

== The features ==

Diff: http://pastebin.ubuntu.com/283667/

lib/devscripts/ec2test/builtins.py

  Create EC2SessionName instances based on EC2TestRunner.name instead
  of passing the name in directly.

lib/devscripts/ec2test/instance.py

  Check that the name is an instance of EC2SessionName, and call new
  method account.collect_garbage() in place of just
  account.delete_previous_key_pairs(). Incidentally, the former calls
  the latter.

lib/devscripts/ec2test/account.py

  Don't try to delete old security groups in
  acquire_security_group(). Removing old security groups is now done
  in delete_previous_security_groups().

  The method delete_previous_security_groups() and
  delete_previous_key_pairs() are very similar in the way they handle
  expiry timestamps.

lib/devscripts/ec2test/session.py
lib/devscripts/ec2test/tests/test_session.py

  Implementation and tests for EC2SessionName. This is a subclass of
  str with a few useful properties and a classmethod to aid in
  generating a good and unique name.

lib/devscripts/ec2test/utils.py
lib/devscripts/ec2test/tests/test_utils.py

  A few functions used to help with generating and parsing session
  names, and their tests.

  make_random_string() is a little unfortunate because there's a uuid
  module in Python 2.5 and beyond that does a better job of
  this. However, this script needs to support Python 2.4, so it'll
  stay for now. It does no harm really I guess.

== The lint fixes ==

http://pastebin.ubuntu.com/283676/

There is some outstanding lint in this branch:

  lib/devscripts/ec2test/builtins.py
    223: [W0102, cmd_test.run] Dangerous default value [] as argument
    289: [W0102, cmd_demo.run] Dangerous default value [] as argument
    363: [W0102, cmd_update_image.run] Dangerous default value [] as argument

  lib/devscripts/ec2test/instance.py
    408: [W0703, EC2Instance.set_up_and_run] Catch "Exception"

There aren't any tests for these scripts other than the ones I've
written in the branch, so I'm going to leave this lint in place
because changing them could affect behaviour. I'm build...

Read more...

Revision history for this message
Abel Deuring (adeuring) wrote :
Download full text (3.9 KiB)

Hi Gavin,

a very nice branch! Just one suggestion:

> + def delete_previous_security_groups(self):
> + """Delete previously used security groups, if found."""
> + def try_delete_group(group):
> + try:
> + group.delete()
> + except EC2ResponseError, e:
> + if e.code != 'InvalidGroup.InUse':
> + raise
> + self.log('Cannot delete; security group '
> + '%r in use.\n' % group.name)
> + now = datetime.utcnow()
> + for group in self.conn.get_all_security_groups():
> + session_name = EC2SessionName(group.name)
> + if session_name in (self.name, self.name.base):
> + self.log('Deleting security group %r\n' % group.name)
> + try_delete_group(group)
> + elif session_name.base == self.name.base:
> + if session_name.expires is None:
> + self.log('Found security group %r without creation '
> + 'date; leaving.\n' % group.name)
> + elif session_name.expires >= now:
> + self.log('Found recent security group %r; '
> + 'leaving\n' % group.name)
> + else:
> + self.log('Deleting expired security '
> + 'group %r\n' % group.name)
> + try_delete_group(group)
> + else:
> + self.log('Found other security group %r; '
> + 'leaving.\n' % group.name)
> +

> + def delete_previous_key_pairs(self):
> + """Delete previously used keypairs, if found."""
> + def try_delete_key_pair(key_pair):
> + try:
> + key_pair.delete()
> + except EC2ResponseError, e:
> + if e.code != 'InvalidKeyPair.NotFound':
> + if e.code == 'AuthFailure':
> + # Inserted because of previous support issue.
> + self.log(
> + 'POSSIBLE CAUSES OF ERROR:\n'
> + ' Did you sign up for EC2?\n'
> + ' Did you put a credit card number in your AWS '
> + 'account?\n'
> + 'Please doublecheck before reporting a '
> + 'problem.\n')
> + raise
> + self.log('Cannot delete; key pair not '
> + 'found %r\n' % key_pair.name)
> + now = datetime.utcnow()
> + for key_pair in self.conn.get_all_key_pairs():
> + session_name = EC2SessionName(key_pair.name)
> + if session_name in (self.name, self.name.base):
> + self.log('Deleting key pair %r\n' % key_pair.name)
> + try_delete_key_pair(key_pair)
> + elif session_name.base == self.name.base:
> + if session_name.expires is None:
> + self.log('Found key pair %r without creation date; '
> + 'leaving.\n' % key_pair.name)
> + elif session_n...

Read more...

review: Approve
Revision history for this message
Gavin Panella (allenap) wrote :

Thanks for the review!

> Hi Gavin,
>
> a very nice branch! Just one suggestion:
>
...
>
> The for loops in both methods are nearly identical; the only differences I see
> are that the log messages contain "key pair" or "secuity group", and that
> try_delete_key_pair() or try_delete_security_group() is called. Would it make
> sense to move the code starting at "if session_name" and ending at "else:
> self.log(..)" into a common method?

Good point. I've done this, and also ditched a lot of the noisy
logging; it now says something only if a key pair or security group is
deleted or if the deletion fails.

Incremental diff: http://pastebin.ubuntu.com/283801/

Thanks, Gavin.

Revision history for this message
Abel Deuring (adeuring) wrote :

Hi Gavin,

On 02.10.2009 14:35, Gavin Panella wrote:
> Thanks for the review!
>
>> Hi Gavin,
>>
>> a very nice branch! Just one suggestion:
>>
> ...
>> The for loops in both methods are nearly identical; the only differences I see
>> are that the log messages contain "key pair" or "secuity group", and that
>> try_delete_key_pair() or try_delete_security_group() is called. Would it make
>> sense to move the code starting at "if session_name" and ending at "else:
>> self.log(..)" into a common method?
>
> Good point. I've done this, and also ditched a lot of the noisy
> logging; it now says something only if a key pair or security group is
> deleted or if the deletion fails.
>
> Incremental diff: http://pastebin.ubuntu.com/283801/

thanks for this change! Looks good

Abel

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/devscripts/ec2test/account.py'
--- lib/devscripts/ec2test/account.py 2009-09-29 15:48:49 +0000
+++ lib/devscripts/ec2test/account.py 2009-10-02 12:34:16 +0000
@@ -13,7 +13,10 @@
13import sys13import sys
14import urllib14import urllib
1515
16from datetime import datetime
17
16from boto.exception import EC2ResponseError18from boto.exception import EC2ResponseError
19from devscripts.ec2test.session import EC2SessionName
1720
18import paramiko21import paramiko
1922
@@ -24,6 +27,13 @@
24 # ...anyone else want in on the fun?27 # ...anyone else want in on the fun?
25 )28 )
2629
30AUTH_FAILURE_MESSAGE = """\
31POSSIBLE CAUSES OF ERROR:
32- Did you sign up for EC2?
33- Did you put a credit card number in your AWS account?
34Please double-check before reporting a problem.
35"""
36
2737
28def get_ip():38def get_ip():
29 """Uses AWS checkip to obtain this machine's IP address.39 """Uses AWS checkip to obtain this machine's IP address.
@@ -64,44 +74,29 @@
64 sys.stdout.write(msg)74 sys.stdout.write(msg)
65 sys.stdout.flush()75 sys.stdout.flush()
6676
77 def _find_expired_artifacts(self, artifacts):
78 now = datetime.utcnow()
79 for artifact in artifacts:
80 session_name = EC2SessionName(artifact.name)
81 if (session_name in (self.name, self.name.base) or (
82 session_name.base == self.name.base and
83 session_name.expires is not None and
84 session_name.expires < now)):
85 yield artifact
86
67 def acquire_security_group(self, demo_networks=None):87 def acquire_security_group(self, demo_networks=None):
68 """Get a security group with the appropriate configuration.88 """Get a security group with the appropriate configuration.
6989
70 "Appropriate" means configured to allow this machine to connect via90 "Appropriate" means configured to allow this machine to connect via
71 SSH, HTTP and HTTPS.91 SSH, HTTP and HTTPS.
7292
73 If a group is already configured with this name for this connection,
74 then re-use that. Otherwise, create a new security group and configure
75 it appropriately.
76
77 The name of the security group is the `EC2Account.name` attribute.93 The name of the security group is the `EC2Account.name` attribute.
7894
79 :return: A boto security group.95 :return: A boto security group.
80 """96 """
81 if demo_networks is None:97 if demo_networks is None:
82 demo_networks = []98 demo_networks = []
83 try:99 # Create the security group.
84 group = self.conn.get_all_security_groups(self.name)[0]
85 except EC2ResponseError, e:
86 if e.code != 'InvalidGroup.NotFound':
87 raise
88 else:
89 # If an existing security group was configured, try deleting it
90 # since our external IP might have changed.
91 try:
92 group.delete()
93 except EC2ResponseError, e:
94 if e.code != 'InvalidGroup.InUse':
95 raise
96 # Otherwise, it means that an instance is already using
97 # it, so simply re-use it. It's unlikely that our IP changed!
98 #
99 # XXX: JonathanLange 2009-06-05: If the security group exists
100 # already, verify that the current IP is permitted; if it is
101 # not, make an INFO log and add the current IP.
102 self.log("Security group already in use, so reusing.\n")
103 return group
104
105 security_group = self.conn.create_security_group(100 security_group = self.conn.create_security_group(
106 self.name, 'Authorization to access the test runner instance.')101 self.name, 'Authorization to access the test runner instance.')
107 # Authorize SSH and HTTP.102 # Authorize SSH and HTTP.
@@ -117,34 +112,49 @@
117 security_group.authorize('tcp', 443, 443, network)112 security_group.authorize('tcp', 443, 443, network)
118 return security_group113 return security_group
119114
115 def delete_previous_security_groups(self):
116 """Delete previously used security groups, if found."""
117 expired_groups = self._find_expired_artifacts(
118 self.conn.get_all_security_groups())
119 for group in expired_groups:
120 try:
121 group.delete()
122 except EC2ResponseError, e:
123 if e.code != 'InvalidGroup.InUse':
124 raise
125 self.log('Cannot delete; security group '
126 '%r in use.\n' % group.name)
127 else:
128 self.log('Deleted security group %r.' % group.name)
129
120 def acquire_private_key(self):130 def acquire_private_key(self):
121 """Create & return a new key pair for the test runner."""131 """Create & return a new key pair for the test runner."""
122 key_pair = self.conn.create_key_pair(self.name)132 key_pair = self.conn.create_key_pair(self.name)
123 return paramiko.RSAKey.from_private_key(133 return paramiko.RSAKey.from_private_key(
124 cStringIO.StringIO(key_pair.material.encode('ascii')))134 cStringIO.StringIO(key_pair.material.encode('ascii')))
125135
126 def delete_previous_key_pair(self):136 def delete_previous_key_pairs(self):
127 """Delete previously used keypair, if it exists."""137 """Delete previously used keypairs, if found."""
128 try:138 expired_key_pairs = self._find_expired_artifacts(
129 # Only one keypair will match 'self.name' since it's a unique139 self.conn.get_all_key_pairs())
130 # identifier.140 for key_pair in expired_key_pairs:
131 key_pairs = self.conn.get_all_key_pairs(self.name)141 try:
132 assert len(key_pairs) == 1, (142 key_pair.delete()
133 "Should be only one keypair, found %d (%s)"143 except EC2ResponseError, e:
134 % (len(key_pairs), key_pairs))144 if e.code != 'InvalidKeyPair.NotFound':
135 key_pair = key_pairs[0]145 if e.code == 'AuthFailure':
136 key_pair.delete()146 # Inserted because of previous support issue.
137 except EC2ResponseError, e:147 self.log(AUTH_FAILURE_MESSAGE)
138 if e.code != 'InvalidKeyPair.NotFound':148 raise
139 if e.code == 'AuthFailure':149 self.log('Cannot delete; key pair not '
140 # Inserted because of previous support issue.150 'found %r\n' % key_pair.name)
141 self.log(151 else:
142 'POSSIBLE CAUSES OF ERROR:\n'152 self.log('Deleted key pair %r.' % key_pair.name)
143 ' Did you sign up for EC2?\n'153
144 ' Did you put a credit card number in your AWS '154 def collect_garbage(self):
145 'account?\n'155 """Remove any old keys and security groups."""
146 'Please doublecheck before reporting a problem.\n')156 self.delete_previous_security_groups()
147 raise157 self.delete_previous_key_pairs()
148158
149 def acquire_image(self, machine_id):159 def acquire_image(self, machine_id):
150 """Get the image.160 """Get the image.
151161
=== modified file 'lib/devscripts/ec2test/builtins.py'
--- lib/devscripts/ec2test/builtins.py 2009-09-30 12:57:13 +0000
+++ lib/devscripts/ec2test/builtins.py 2009-10-02 12:34:16 +0000
@@ -23,6 +23,7 @@
23from devscripts.ec2test.credentials import EC2Credentials23from devscripts.ec2test.credentials import EC2Credentials
24from devscripts.ec2test.instance import (24from devscripts.ec2test.instance import (
25 AVAILABLE_INSTANCE_TYPES, DEFAULT_INSTANCE_TYPE, EC2Instance)25 AVAILABLE_INSTANCE_TYPES, DEFAULT_INSTANCE_TYPE, EC2Instance)
26from devscripts.ec2test.session import EC2SessionName
26from devscripts.ec2test.testrunner import EC2TestRunner, TRUNK_BRANCH27from devscripts.ec2test.testrunner import EC2TestRunner, TRUNK_BRANCH
2728
2829
@@ -168,52 +169,52 @@
168 help=('Store abridged test results in FILE.')),169 help=('Store abridged test results in FILE.')),
169 ListOption(170 ListOption(
170 'email', short_name='e', argname='EMAIL', type=str,171 'email', short_name='e', argname='EMAIL', type=str,
171 help=('Email address to which results should be mailed. Defaults to '172 help=('Email address to which results should be mailed. Defaults '
172 'the email address from `bzr whoami`. May be supplied multiple '173 'to the email address from `bzr whoami`. May be supplied '
173 'times. The first supplied email address will be used as the '174 'multiple times. The first supplied email address will be '
174 'From: address.')),175 'used as the From: address.')),
175 Option(176 Option(
176 'noemail', short_name='n',177 'noemail', short_name='n',
177 help=('Do not try to email results.')),178 help=('Do not try to email results.')),
178 Option(179 Option(
179 'test-options', short_name='o', type=str,180 'test-options', short_name='o', type=str,
180 help=('Test options to pass to the remote test runner. Defaults to '181 help=('Test options to pass to the remote test runner. Defaults '
181 "``-o '-vv'``. For instance, to run specific tests, you might "182 "to ``-o '-vv'``. For instance, to run specific tests, "
182 "use ``-o '-vvt my_test_pattern'``.")),183 "you might use ``-o '-vvt my_test_pattern'``.")),
183 Option(184 Option(
184 'submit-pqm-message', short_name='s', type=str, argname="MSG",185 'submit-pqm-message', short_name='s', type=str, argname="MSG",
185 help=('A pqm message to submit if the test run is successful. If '186 help=('A pqm message to submit if the test run is successful. '
186 'provided, you will be asked for your GPG passphrase before '187 'If provided, you will be asked for your GPG passphrase '
187 'the test run begins.')),188 'before the test run begins.')),
188 Option(189 Option(
189 'pqm-public-location', type=str,190 'pqm-public-location', type=str,
190 help=('The public location for the pqm submit, if a pqm message is '191 help=('The public location for the pqm submit, if a pqm message '
191 'provided (see --submit-pqm-message). If this is not provided, '192 'is provided (see --submit-pqm-message). If this is not '
192 'for local branches, bzr configuration is consulted; for '193 'provided, for local branches, bzr configuration is '
193 'remote branches, it is assumed that the remote branch *is* '194 'consulted; for remote branches, it is assumed that the '
194 'a public branch.')),195 'remote branch *is* a public branch.')),
195 Option(196 Option(
196 'pqm-submit-location', type=str,197 'pqm-submit-location', type=str,
197 help=('The submit location for the pqm submit, if a pqm message is '198 help=('The submit location for the pqm submit, if a pqm message '
198 'provided (see --submit-pqm-message). If this option is not '199 'is provided (see --submit-pqm-message). If this option '
199 'provided, the script will look for an explicitly specified '200 'is not provided, the script will look for an explicitly '
200 'launchpad branch using the -b/--branch option; if that branch '201 'specified launchpad branch using the -b/--branch option; '
201 'was specified and is owned by the launchpad-pqm user on '202 'if that branch was specified and is owned by the '
202 'launchpad, it is used as the pqm submit location. Otherwise, '203 'launchpad-pqm user on launchpad, it is used as the pqm '
203 'for local branches, bzr configuration is consulted; for '204 'submit location. Otherwise, for local branches, bzr '
204 'remote branches, it is assumed that the submit branch is %s.'205 'configuration is consulted; for remote branches, it is '
205 % (TRUNK_BRANCH,))),206 'assumed that the submit branch is %s.' % (TRUNK_BRANCH,))),
206 Option(207 Option(
207 'pqm-email', type=str,208 'pqm-email', type=str,
208 help=('Specify the email address of the PQM you are submitting to. '209 help=('Specify the email address of the PQM you are submitting '
209 'If the branch is local, then the bzr configuration is '210 'to. If the branch is local, then the bzr configuration is '
210 'consulted; for remote branches "Launchpad PQM '211 'consulted; for remote branches "Launchpad PQM '
211 '<launchpad@pqm.canonical.com>" is used by default.')),212 '<launchpad@pqm.canonical.com>" is used by default.')),
212 postmortem_option,213 postmortem_option,
213 Option(214 Option(
214 'headless',215 'headless',
215 help=('After building the instance and test, run the remote tests '216 help=('After building the instance and test, run the remote '
216 'headless. Cannot be used with postmortem '217 'tests headless. Cannot be used with postmortem '
217 'or file.')),218 'or file.')),
218 debug_option,219 debug_option,
219 Option(220 Option(
@@ -252,9 +253,9 @@
252 'You have specified no way to get the results '253 'You have specified no way to get the results '
253 'of your headless test run.')254 'of your headless test run.')
254255
255256 session_name = EC2SessionName.make(EC2TestRunner.name)
256 instance = EC2Instance.make(257 instance = EC2Instance.make(
257 EC2TestRunner.name, instance_type, machine)258 session_name, instance_type, machine)
258259
259 runner = EC2TestRunner(260 runner = EC2TestRunner(
260 test_branch, email=email, file=file,261 test_branch, email=email, file=file,
@@ -381,8 +382,9 @@
381 branches, test_branch = _get_branches_and_test_branch(382 branches, test_branch = _get_branches_and_test_branch(
382 trunk, branch, test_branch)383 trunk, branch, test_branch)
383384
385 session_name = EC2SessionName.make(EC2TestRunner.name)
384 instance = EC2Instance.make(386 instance = EC2Instance.make(
385 EC2TestRunner.name, instance_type, machine, demo)387 session_name, instance_type, machine, demo)
386388
387 runner = EC2TestRunner(389 runner = EC2TestRunner(
388 test_branch, branches=branches,390 test_branch, branches=branches,
@@ -435,8 +437,9 @@
435 ListOption(437 ListOption(
436 'extra-update-image-command', type=str,438 'extra-update-image-command', type=str,
437 help=('Run this command (with an ssh agent) on the image before '439 help=('Run this command (with an ssh agent) on the image before '
438 'running the default update steps. Can be passed more than '440 'running the default update steps. Can be passed more '
439 'once, the commands will be run in the order specified.')),441 'than once, the commands will be run in the order '
442 'specified.')),
440 Option(443 Option(
441 'public',444 'public',
442 help=('Remove proprietary code from the sourcecode directory '445 help=('Remove proprietary code from the sourcecode directory '
@@ -453,8 +456,9 @@
453456
454 credentials = EC2Credentials.load_from_file()457 credentials = EC2Credentials.load_from_file()
455458
459 session_name = EC2SessionName.make(EC2TestRunner.name)
456 instance = EC2Instance.make(460 instance = EC2Instance.make(
457 EC2TestRunner.name, instance_type, machine,461 session_name, instance_type, machine,
458 credentials=credentials)462 credentials=credentials)
459 instance.check_bundling_prerequisites()463 instance.check_bundling_prerequisites()
460464
@@ -489,7 +493,8 @@
489 user_connection.run_with_ssh_agent(493 user_connection.run_with_ssh_agent(
490 'bzr pull -d /var/launchpad/test ' + TRUNK_BRANCH)494 'bzr pull -d /var/launchpad/test ' + TRUNK_BRANCH)
491 user_connection.run_with_ssh_agent(495 user_connection.run_with_ssh_agent(
492 'bzr pull -d /var/launchpad/download-cache lp:lp-source-dependencies')496 'bzr pull -d /var/launchpad/download-cache '
497 'lp:lp-source-dependencies')
493 if public:498 if public:
494 update_sourcecode_options = '--public-only'499 update_sourcecode_options = '--public-only'
495 else:500 else:
496501
=== modified file 'lib/devscripts/ec2test/instance.py'
--- lib/devscripts/ec2test/instance.py 2009-09-27 20:36:18 +0000
+++ lib/devscripts/ec2test/instance.py 2009-10-02 12:34:16 +0000
@@ -24,6 +24,7 @@
24import paramiko24import paramiko
2525
26from devscripts.ec2test.credentials import EC2Credentials26from devscripts.ec2test.credentials import EC2Credentials
27from devscripts.ec2test.session import EC2SessionName
2728
2829
29DEFAULT_INSTANCE_TYPE = 'c1.xlarge'30DEFAULT_INSTANCE_TYPE = 'c1.xlarge'
@@ -159,6 +160,18 @@
159"""160"""
160161
161162
163postmortem_banner = """\
164Postmortem Console. EC2 instance is not yet dead.
165It will shut down when you exit this prompt (CTRL-D)
166
167Tab-completion is enabled.
168EC2Instance is available as `instance`.
169Also try these:
170 http://%(dns)s/current_test.log
171 ssh -A %(dns)s
172"""
173
174
162class EC2Instance:175class EC2Instance:
163 """A single EC2 instance."""176 """A single EC2 instance."""
164177
@@ -169,6 +182,7 @@
169182
170 :param name: The name to use for the key pair and security group for183 :param name: The name to use for the key pair and security group for
171 the instance.184 the instance.
185 :type name: `EC2SessionName`
172 :param instance_type: One of the AVAILABLE_INSTANCE_TYPES.186 :param instance_type: One of the AVAILABLE_INSTANCE_TYPES.
173 :param machine_id: The AMI to use, or None to do the usual regexp187 :param machine_id: The AMI to use, or None to do the usual regexp
174 matching. If you put 'based-on:' before the AMI id, it is assumed188 matching. If you put 'based-on:' before the AMI id, it is assumed
@@ -179,6 +193,7 @@
179 to allow access to the instance.193 to allow access to the instance.
180 :param credentials: An `EC2Credentials` object.194 :param credentials: An `EC2Credentials` object.
181 """195 """
196 assert isinstance(name, EC2SessionName)
182 if instance_type not in AVAILABLE_INSTANCE_TYPES:197 if instance_type not in AVAILABLE_INSTANCE_TYPES:
183 raise ValueError('unknown instance_type %s' % (instance_type,))198 raise ValueError('unknown instance_type %s' % (instance_type,))
184199
@@ -199,7 +214,7 @@
199 # We always recreate the keypairs because there is no way to214 # We always recreate the keypairs because there is no way to
200 # programmatically retrieve the private key component, unless we215 # programmatically retrieve the private key component, unless we
201 # generate it.216 # generate it.
202 account.delete_previous_key_pair()217 account.collect_garbage()
203218
204 if machine_id and machine_id.startswith('based-on:'):219 if machine_id and machine_id.startswith('based-on:'):
205 from_scratch = True220 from_scratch = True
@@ -320,7 +335,8 @@
320 """335 """
321 authorized_keys_file = conn.sftp.open(remote_filename, 'w')336 authorized_keys_file = conn.sftp.open(remote_filename, 'w')
322 authorized_keys_file.write(337 authorized_keys_file.write(
323 "%s %s\n" % (self._user_key.get_name(), self._user_key.get_base64()))338 "%s %s\n" % (self._user_key.get_name(),
339 self._user_key.get_base64()))
324 authorized_keys_file.close()340 authorized_keys_file.close()
325341
326 def _ensure_ec2test_user_has_keys(self, connection=None):342 def _ensure_ec2test_user_has_keys(self, connection=None):
@@ -345,8 +361,8 @@
345 if our_connection:361 if our_connection:
346 connection.close()362 connection.close()
347 self.log(363 self.log(
348 'You can now use ssh -A ec2test@%s to log in the instance.\n' %364 'You can now use ssh -A ec2test@%s to '
349 self.hostname)365 'log in the instance.\n' % self.hostname)
350 self._ec2test_user_has_keys = True366 self._ec2test_user_has_keys = True
351367
352 def connect(self):368 def connect(self):
@@ -390,25 +406,17 @@
390 try:406 try:
391 return func(*args, **kw)407 return func(*args, **kw)
392 except Exception:408 except Exception:
393 # When running in postmortem mode, it is really helpful to see if409 # When running in postmortem mode, it is really helpful to see
394 # there are any exceptions before it waits in the console (in the410 # if there are any exceptions before it waits in the console
395 # finally block), and you can't figure out why it's broken.411 # (in the finally block), and you can't figure out why it's
412 # broken.
396 traceback.print_exc()413 traceback.print_exc()
397 finally:414 finally:
398 try:415 try:
399 if postmortem:416 if postmortem:
400 console = code.InteractiveConsole(locals())417 console = code.InteractiveConsole(locals())
401 console.interact((418 console.interact(
402 'Postmortem Console. EC2 instance is not yet dead.\n'419 postmortem_banner % {'dns': self.hostname})
403 'It will shut down when you exit this prompt (CTRL-D).\n'
404 '\n'
405 'Tab-completion is enabled.'
406 '\n'
407 'EC2Instance is available as `instance`.\n'
408 'Also try these:\n'
409 ' http://%(dns)s/current_test.log\n'
410 ' ssh -A %(dns)s') %
411 {'dns': self.hostname})
412 print 'Postmortem console closed.'420 print 'Postmortem console closed.'
413 finally:421 finally:
414 if shutdown:422 if shutdown:
415423
=== added file 'lib/devscripts/ec2test/session.py'
--- lib/devscripts/ec2test/session.py 1970-01-01 00:00:00 +0000
+++ lib/devscripts/ec2test/session.py 2009-10-02 12:34:16 +0000
@@ -0,0 +1,90 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Code to represent a single session of EC2 use."""
5
6__metaclass__ = type
7__all__ = [
8 'EC2SessionName',
9 ]
10
11from datetime import datetime, timedelta
12
13from devscripts.ec2test.utils import (
14 find_datetime_string, make_datetime_string, make_random_string)
15
16
17DEFAULT_LIFETIME = timedelta(hours=6)
18
19
20class EC2SessionName(str):
21 """A name for an EC2 session.
22
23 This is used when naming key pairs and security groups, so it's
24 useful to be unique. However, to aid garbage collection of old key
25 pairs and security groups, the name contains a common element and
26 an expiry timestamp. The form taken should always be:
27
28 <base-name>/<expires-timestamp>/<random-data>
29
30 None of the parts should contain forward-slashes, and the
31 timestamp should be acceptable input to `find_datetime_string`.
32
33 `EC2SessionName.make()` will generate a suitable name given a
34 suitable base name.
35 """
36
37 @classmethod
38 def make(cls, base, expires=None):
39 """Create an `EC2SessionName`.
40
41 This checks that `base` does not contain a forward-slash, and
42 provides some convenient functionality for `expires`:
43
44 - If `expires` is None, it defaults to now (UTC) plus
45 `DEFAULT_LIFETIME`.
46
47 - If `expires` is a `datetime`, it is converted to a timestamp
48 in the correct form.
49
50 - If `expires` is a `timedelta`, it is added to now (UTC) then
51 converted to a timestamp.
52
53 - Otherwise `expires` is assumed to be a string, so is checked
54 for the absense of forward-slashes, and that a correctly
55 formed timestamp can be discovered.
56
57 """
58 assert '/' not in base
59 if expires is None:
60 expires = DEFAULT_LIFETIME
61 if isinstance(expires, timedelta):
62 expires = datetime.utcnow() + expires
63 if isinstance(expires, datetime):
64 expires = make_datetime_string(expires)
65 else:
66 assert '/' not in expires
67 assert find_datetime_string(expires) is not None
68 rand = make_random_string(8)
69 return cls("%s/%s/%s" % (base, expires, rand))
70
71 @property
72 def base(self):
73 parts = self.split('/')
74 if len(parts) != 3:
75 return None
76 return parts[0]
77
78 @property
79 def expires(self):
80 parts = self.split('/')
81 if len(parts) != 3:
82 return None
83 return find_datetime_string(parts[1])
84
85 @property
86 def rand(self):
87 parts = self.split('/')
88 if len(parts) != 3:
89 return None
90 return parts[2]
091
=== added directory 'lib/devscripts/ec2test/tests'
=== added file 'lib/devscripts/ec2test/tests/__init__.py'
--- lib/devscripts/ec2test/tests/__init__.py 1970-01-01 00:00:00 +0000
+++ lib/devscripts/ec2test/tests/__init__.py 2009-10-02 12:34:16 +0000
@@ -0,0 +1,2 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
03
=== added file 'lib/devscripts/ec2test/tests/test_session.py'
--- lib/devscripts/ec2test/tests/test_session.py 1970-01-01 00:00:00 +0000
+++ lib/devscripts/ec2test/tests/test_session.py 2009-10-02 12:34:16 +0000
@@ -0,0 +1,69 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Test the session module."""
5
6__metaclass__ = type
7
8import re
9import unittest
10
11from datetime import datetime, timedelta
12
13from devscripts.ec2test import session
14
15
16class TestEC2SessionName(unittest.TestCase):
17 """Tests for EC2SessionName."""
18
19 def test_make(self):
20 # EC2SessionName.make() is the most convenient way to create
21 # valid names.
22 name = session.EC2SessionName.make("fred")
23 check = re.compile(
24 r'^fred/\d{4}-\d{2}-\d{2}-\d{4}/[0-9a-zA-Z]{8}$').match
25 self.failIf(check(name) is None, "Did not match %r" % name)
26 possible_expires = [
27 None, '1986-04-26-0123', timedelta(hours=10),
28 datetime(1986, 04, 26, 1, 23)
29 ]
30 for expires in possible_expires:
31 name = session.EC2SessionName.make("fred", expires)
32 self.failIf(check(name) is None, "Did not match %r" % name)
33
34 def test_properties(self):
35 # A valid EC2SessionName has properies to access the three
36 # components of its name.
37 base = "fred"
38 timestamp = datetime(1986, 4, 26, 1, 23)
39 timestamp_string = '1986-04-26-0123'
40 rand = 'abcdef123456'
41 name = session.EC2SessionName(
42 "%s/%s/%s" % (base, timestamp_string, rand))
43 self.failUnlessEqual(base, name.base)
44 self.failUnlessEqual(timestamp, name.expires)
45 self.failUnlessEqual(rand, name.rand)
46
47 def test_invalid_base(self):
48 # If the given base contains a forward-slash, an
49 # AssertionError should be raised.
50 self.failUnlessRaises(
51 AssertionError, session.EC2SessionName.make, "forward/slash")
52
53 def test_invalid_timestamp(self):
54 # If the given expiry timestamp contains a forward-slash, an
55 # AssertionError should be raised.
56 self.failUnlessRaises(
57 AssertionError, session.EC2SessionName.make, "fred", "/")
58 # If the given expiry timestamp does not contain a timestamp
59 # in the correct form, an AssertionError should be raised.
60 self.failUnlessRaises(
61 AssertionError, session.EC2SessionName.make, "fred", "1986.04.26")
62
63 def test_form_not_correct(self):
64 # If the form of the string is not base/timestamp/rand then
65 # the corresponding properties should all return None.
66 broken_name = session.EC2SessionName('bob')
67 self.failUnless(broken_name.base is None)
68 self.failUnless(broken_name.expires is None)
69 self.failUnless(broken_name.rand is None)
070
=== added file 'lib/devscripts/ec2test/tests/test_utils.py'
--- lib/devscripts/ec2test/tests/test_utils.py 1970-01-01 00:00:00 +0000
+++ lib/devscripts/ec2test/tests/test_utils.py 2009-10-02 12:34:16 +0000
@@ -0,0 +1,61 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Test the utils module."""
5
6__metaclass__ = type
7
8import unittest
9
10from datetime import datetime
11
12from devscripts.ec2test import utils
13
14
15class TestDateTimeUtils(unittest.TestCase):
16 """Tests for date/time related utilities."""
17
18 example_date = datetime(1986, 4, 26, 1, 23)
19 example_date_string = '1986-04-26-0123'
20 example_date_text = (
21 'blah blah foo blah 23545 646 ' +
22 example_date_string + ' 435 blah')
23
24 def test_make_datetime_string(self):
25 self.failUnlessEqual(
26 self.example_date_string,
27 utils.make_datetime_string(self.example_date))
28
29 def test_find_datetime_string(self):
30 self.failUnlessEqual(
31 self.example_date,
32 utils.find_datetime_string(self.example_date_string))
33 self.failUnlessEqual(
34 self.example_date,
35 utils.find_datetime_string(self.example_date_text))
36
37
38class TestRandomUtils(unittest.TestCase):
39 """Tests for randomness related utilities."""
40
41 hex_chars = frozenset('0123456789abcdefABCDEF')
42
43 def test_make_random_string(self):
44 rand_a = utils.make_random_string()
45 rand_b = utils.make_random_string()
46 self.failIfEqual(rand_a, rand_b)
47 self.failUnlessEqual(32, len(rand_a))
48 self.failUnlessEqual(32, len(rand_b))
49 self.failUnless(self.hex_chars.issuperset(rand_a))
50 self.failUnless(self.hex_chars.issuperset(rand_b))
51
52 def test_make_random_string_with_length(self):
53 for length in (8, 16, 64):
54 rand = utils.make_random_string(length)
55 self.failUnlessEqual(length, len(rand))
56 self.failUnless(self.hex_chars.issuperset(rand))
57
58 def test_make_random_string_with_bad_length(self):
59 # length must be a multiple of 2.
60 self.failUnlessRaises(
61 AssertionError, utils.make_random_string, 15)
062
=== added file 'lib/devscripts/ec2test/utils.py'
--- lib/devscripts/ec2test/utils.py 1970-01-01 00:00:00 +0000
+++ lib/devscripts/ec2test/utils.py 2009-10-02 12:34:16 +0000
@@ -0,0 +1,55 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""General useful stuff."""
5
6__metaclass__ = type
7__all__ = [
8 'find_datetime_string',
9 'make_datetime_string',
10 'make_random_string',
11 ]
12
13
14import binascii
15import datetime
16import os
17import re
18
19
20def make_datetime_string(when=None):
21 """Generate a simple formatted date and time string.
22
23 This is intended to be embedded in text to be later found by
24 `find_datetime_string`.
25 """
26 if when is None:
27 when = datetime.datetime.utcnow()
28 return when.strftime('%Y-%m-%d-%H%M')
29
30
31re_find_datetime = re.compile(
32 r'(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})')
33
34def find_datetime_string(text):
35 """Search for a simple date and time in arbitrary text.
36
37 The format searched for is %Y-%m-%d-%H%M - the same as produced by
38 `make_datetime_string`.
39 """
40 match = re_find_datetime.search(text)
41 if match is None:
42 return None
43 else:
44 return datetime.datetime(
45 *(int(part) for part in match.groups()))
46
47
48def make_random_string(length=32):
49 """Return a simple random UUID.
50
51 The uuid module is only available in Python 2.5 and above, but a
52 simple non-RFC-compliant hack here is sufficient.
53 """
54 assert length % 2 == 0, "length must be a multiple of 2"
55 return binascii.hexlify(os.urandom(length/2))