Merge lp:~mwhudson/launchpad/libraryize-ec2test into lp:launchpad
- libraryize-ec2test
- Merge into devel
Proposed by
Michael Hudson-Doyle
Status: | Merged |
---|---|
Merged at revision: | not available |
Proposed branch: | lp:~mwhudson/launchpad/libraryize-ec2test |
Merge into: | lp:launchpad |
Diff against target: | None lines |
To merge this branch: | bzr merge lp:~mwhudson/launchpad/libraryize-ec2test |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Jonathan Lange (community) | Needs Fixing | ||
Review via email:
|
Commit message
Description of the change
To post a comment you must log in.
Revision history for this message
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Michael Hudson-Doyle (mwhudson) wrote : | # |
Revision history for this message
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Jonathan Lange (jml) wrote : | # |
Hey Michael,
Thanks for doing this. I really like the direction this is going in.
In private email, we discussed how the branch parsing stuff really belongs in the commandline module, but that we want to keep the code changes minimal at this point.
In a similar spirit, I think the Paramiko hacks should live in their own module. Would this be too much of a change?
I don't think that the utilities/
Obviously, landing this branch depends on fixing up & landing my better-
cheers,
jml
review:
Needs Fixing
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'buildout-templates/bin/test.in' |
2 | --- buildout-templates/bin/test.in 2009-08-10 22:08:05 +0000 |
3 | +++ buildout-templates/bin/test.in 2009-09-10 00:17:39 +0000 |
4 | @@ -140,6 +140,7 @@ |
5 | '--test-path=${buildout:directory}/lib', |
6 | '--package=canonical', |
7 | '--package=lp', |
8 | + '--package=devscripts', |
9 | '--layer=!MailmanLayer', |
10 | ] |
11 | |
12 | |
13 | === added directory 'lib/devscripts' |
14 | === added file 'lib/devscripts/__init__.py' |
15 | --- lib/devscripts/__init__.py 1970-01-01 00:00:00 +0000 |
16 | +++ lib/devscripts/__init__.py 2009-09-09 03:27:47 +0000 |
17 | @@ -0,0 +1,4 @@ |
18 | +# Copyright 2009 Canonical Ltd. This software is licensed under the |
19 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
20 | + |
21 | +"""Scripts that are used in developing Launchpad.""" |
22 | |
23 | === added directory 'lib/devscripts/ec2test' |
24 | === renamed file 'utilities/ec2test.py' => 'lib/devscripts/ec2test/__init__.py' (properties changed: +x to -x) |
25 | --- utilities/ec2test.py 2009-09-06 10:04:18 +0000 |
26 | +++ lib/devscripts/ec2test/__init__.py 2009-09-10 23:59:12 +0000 |
27 | @@ -1,55 +1,16 @@ |
28 | -#!/usr/bin/python |
29 | -# Run tests on a branch in an EC2 instance. |
30 | -# |
31 | # Copyright 2009 Canonical Ltd. This software is licensed under the |
32 | # GNU Affero General Public License version 3 (see the file LICENSE). |
33 | |
34 | -__metatype__ = type |
35 | - |
36 | -import cStringIO |
37 | -import code |
38 | -import optparse |
39 | -import os |
40 | -import pickle |
41 | -import re |
42 | -import select |
43 | -import socket |
44 | -import subprocess |
45 | -import sys |
46 | -import time |
47 | -import urllib |
48 | -import traceback |
49 | -# The rlcompleter and readline modules change the behavior of the python |
50 | -# interactive interpreter just by being imported. |
51 | -import readline |
52 | -import rlcompleter |
53 | -# Shut up pyflakes. |
54 | -rlcompleter |
55 | - |
56 | -import boto |
57 | -from boto.exception import EC2ResponseError |
58 | -from bzrlib.branch import Branch |
59 | -from bzrlib.bzrdir import BzrDir |
60 | -from bzrlib.config import GlobalConfig |
61 | -from bzrlib.errors import UncommittedChanges |
62 | -from bzrlib.plugins.launchpad.account import get_lp_login |
63 | -from bzrlib.plugins.pqm.pqm_submit import ( |
64 | - NoPQMSubmissionAddress, PQMSubmission) |
65 | +"""Run the Launchpad tests in Amazon's Elastic Compute Cloud (EC2).""" |
66 | + |
67 | +__metaclass__ = type |
68 | + |
69 | +__all__ = [ |
70 | + 'main', |
71 | + ] |
72 | + |
73 | import paramiko |
74 | |
75 | - |
76 | -TRUNK_BRANCH = 'bzr+ssh://bazaar.launchpad.net/~launchpad-pqm/launchpad/devel' |
77 | -DEFAULT_INSTANCE_TYPE = 'c1.xlarge' |
78 | -AVAILABLE_INSTANCE_TYPES = ('m1.large', 'm1.xlarge', 'c1.xlarge') |
79 | -VALID_AMI_OWNERS = ( |
80 | - 255383312499, # gary |
81 | - 559320013529, # flacoste |
82 | - 200337130613, # mwhudson |
83 | - # ...anyone else want in on the fun? |
84 | - ) |
85 | - |
86 | -readline.parse_and_bind('tab: complete') |
87 | - |
88 | ############################################################################# |
89 | # Try to guide users past support problems we've encountered before |
90 | if not paramiko.__version__.startswith('1.7.4'): |
91 | @@ -59,1539 +20,5 @@ |
92 | # End |
93 | ############################################################################# |
94 | |
95 | -############################################################################# |
96 | -# Modified from paramiko.config. The change should be pushed upstream. |
97 | -# Our fork supports Host lines with more than one host. |
98 | - |
99 | -import fnmatch |
100 | - |
101 | - |
102 | -class SSHConfig (object): |
103 | - """ |
104 | - Representation of config information as stored in the format used by |
105 | - OpenSSH. Queries can be made via L{lookup}. The format is described in |
106 | - OpenSSH's C{ssh_config} man page. This class is provided primarily as a |
107 | - convenience to posix users (since the OpenSSH format is a de-facto |
108 | - standard on posix) but should work fine on Windows too. |
109 | - |
110 | - @since: 1.6 |
111 | - """ |
112 | - |
113 | - def __init__(self): |
114 | - """ |
115 | - Create a new OpenSSH config object. |
116 | - """ |
117 | - self._config = [ { 'host': '*' } ] |
118 | - |
119 | - def parse(self, file_obj): |
120 | - """ |
121 | - Read an OpenSSH config from the given file object. |
122 | - |
123 | - @param file_obj: a file-like object to read the config file from |
124 | - @type file_obj: file |
125 | - """ |
126 | - configs = [self._config[0]] |
127 | - for line in file_obj: |
128 | - line = line.rstrip('\n').lstrip() |
129 | - if (line == '') or (line[0] == '#'): |
130 | - continue |
131 | - if '=' in line: |
132 | - key, value = line.split('=', 1) |
133 | - key = key.strip().lower() |
134 | - else: |
135 | - # find first whitespace, and split there |
136 | - i = 0 |
137 | - while (i < len(line)) and not line[i].isspace(): |
138 | - i += 1 |
139 | - if i == len(line): |
140 | - raise Exception('Unparsable line: %r' % line) |
141 | - key = line[:i].lower() |
142 | - value = line[i:].lstrip() |
143 | - |
144 | - if key == 'host': |
145 | - del configs[:] |
146 | - # the value may be multiple hosts, space-delimited |
147 | - for host in value.split(): |
148 | - # do we have a pre-existing host config to append to? |
149 | - matches = [c for c in self._config if c['host'] == host] |
150 | - if len(matches) > 0: |
151 | - configs.append(matches[0]) |
152 | - else: |
153 | - config = { 'host': host } |
154 | - self._config.append(config) |
155 | - configs.append(config) |
156 | - else: |
157 | - for config in configs: |
158 | - config[key] = value |
159 | - |
160 | - def lookup(self, hostname): |
161 | - """ |
162 | - Return a dict of config options for a given hostname. |
163 | - |
164 | - The host-matching rules of OpenSSH's C{ssh_config} man page are used, |
165 | - which means that all configuration options from matching host |
166 | - specifications are merged, with more specific hostmasks taking |
167 | - precedence. In other words, if C{"Port"} is set under C{"Host *"} |
168 | - and also C{"Host *.example.com"}, and the lookup is for |
169 | - C{"ssh.example.com"}, then the port entry for C{"Host *.example.com"} |
170 | - will win out. |
171 | - |
172 | - The keys in the returned dict are all normalized to lowercase (look for |
173 | - C{"port"}, not C{"Port"}. No other processing is done to the keys or |
174 | - values. |
175 | - |
176 | - @param hostname: the hostname to lookup |
177 | - @type hostname: str |
178 | - """ |
179 | - matches = [ |
180 | - x for x in self._config if fnmatch.fnmatch(hostname, x['host'])] |
181 | - # sort in order of shortest match (usually '*') to longest |
182 | - matches.sort(lambda x,y: cmp(len(x['host']), len(y['host']))) |
183 | - ret = {} |
184 | - for m in matches: |
185 | - ret.update(m) |
186 | - del ret['host'] |
187 | - return ret |
188 | - |
189 | -# END paramiko config fork |
190 | -############################################################################# |
191 | - |
192 | - |
193 | -def get_ip(): |
194 | - """Uses AWS checkip to obtain this machine's IP address. |
195 | - |
196 | - Consults an external website to determine the public IP address of this |
197 | - machine. |
198 | - |
199 | - :return: This machine's net-visible IP address as a string. |
200 | - """ |
201 | - return urllib.urlopen('http://checkip.amazonaws.com').read().strip() |
202 | - |
203 | - |
204 | -class CredentialsError(Exception): |
205 | - """Raised when AWS credentials could not be loaded.""" |
206 | - |
207 | - def __init__(self, filename, extra=None): |
208 | - message = ( |
209 | - "Please put your aws access key identifier and secret access " |
210 | - "key identifier in %s. (On two lines)." % (filename,)) |
211 | - if extra: |
212 | - message += extra |
213 | - Exception.__init__(self, message) |
214 | - |
215 | - |
216 | -class EC2Credentials: |
217 | - """Credentials for logging in to EC2.""" |
218 | - |
219 | - DEFAULT_CREDENTIALS_FILE = '~/.ec2/aws_id' |
220 | - |
221 | - def __init__(self, identifier, secret): |
222 | - self.identifier = identifier |
223 | - self.secret = secret |
224 | - |
225 | - @classmethod |
226 | - def load_from_file(cls, filename=None): |
227 | - """Load the EC2 credentials from 'filename'.""" |
228 | - if filename is None: |
229 | - filename = os.path.expanduser(cls.DEFAULT_CREDENTIALS_FILE) |
230 | - try: |
231 | - aws_file = open(filename, 'r') |
232 | - except (IOError, OSError), e: |
233 | - raise CredentialsError(filename, str(e)) |
234 | - try: |
235 | - identifier = aws_file.readline().strip() |
236 | - secret = aws_file.readline().strip() |
237 | - finally: |
238 | - aws_file.close() |
239 | - return cls(identifier, secret) |
240 | - |
241 | - def connect(self, name): |
242 | - """Connect to EC2 with these credentials. |
243 | - |
244 | - :param name: ??? |
245 | - :return: An `EC2Account` connected to EC2 with these credentials. |
246 | - """ |
247 | - conn = boto.connect_ec2(self.identifier, self.secret) |
248 | - return EC2Account(name, conn) |
249 | - |
250 | - |
251 | -class EC2Account: |
252 | - """An EC2 account. |
253 | - |
254 | - You can use this to manage security groups, keys and images for an EC2 |
255 | - account. |
256 | - """ |
257 | - |
258 | - # Used to find pre-configured Amazon images. |
259 | - _image_match = re.compile( |
260 | - r'launchpad-ec2test(\d+)/image.manifest.xml$').match |
261 | - |
262 | - def __init__(self, name, connection): |
263 | - """Construct an EC2 instance. |
264 | - |
265 | - :param name: ??? |
266 | - :param connection: An open boto ec2 connection. |
267 | - """ |
268 | - self.name = name |
269 | - self.conn = connection |
270 | - |
271 | - def log(self, msg): |
272 | - """Log a message on stdout, flushing afterwards.""" |
273 | - # XXX: JonathanLange 2009-05-31 bug=383076: Copied from EC2TestRunner. |
274 | - # Should change EC2Account to take a logger and use that instead of |
275 | - # writing to stdout. |
276 | - sys.stdout.write(msg) |
277 | - sys.stdout.flush() |
278 | - |
279 | - def acquire_security_group(self, demo_networks=None): |
280 | - """Get a security group with the appropriate configuration. |
281 | - |
282 | - "Appropriate" means configured to allow this machine to connect via |
283 | - SSH, HTTP and HTTPS. |
284 | - |
285 | - If a group is already configured with this name for this connection, |
286 | - then re-use that. Otherwise, create a new security group and configure |
287 | - it appropriately. |
288 | - |
289 | - The name of the security group is the `EC2Account.name` attribute. |
290 | - |
291 | - :return: A boto security group. |
292 | - """ |
293 | - if demo_networks is None: |
294 | - demo_networks = [] |
295 | - try: |
296 | - group = self.conn.get_all_security_groups(self.name)[0] |
297 | - except EC2ResponseError, e: |
298 | - if e.code != 'InvalidGroup.NotFound': |
299 | - raise |
300 | - else: |
301 | - # If an existing security group was configured, try deleting it |
302 | - # since our external IP might have changed. |
303 | - try: |
304 | - group.delete() |
305 | - except EC2ResponseError, e: |
306 | - if e.code != 'InvalidGroup.InUse': |
307 | - raise |
308 | - # Otherwise, it means that an instance is already using |
309 | - # it, so simply re-use it. It's unlikely that our IP changed! |
310 | - # |
311 | - # XXX: JonathanLange 2009-06-05: If the security group exists |
312 | - # already, verify that the current IP is permitted; if it is |
313 | - # not, make an INFO log and add the current IP. |
314 | - self.log("Security group already in use, so reusing.") |
315 | - return group |
316 | - |
317 | - security_group = self.conn.create_security_group( |
318 | - self.name, 'Authorization to access the test runner instance.') |
319 | - # Authorize SSH and HTTP. |
320 | - ip = get_ip() |
321 | - security_group.authorize('tcp', 22, 22, '%s/32' % ip) |
322 | - security_group.authorize('tcp', 80, 80, '%s/32' % ip) |
323 | - security_group.authorize('tcp', 443, 443, '%s/32' % ip) |
324 | - for network in demo_networks: |
325 | - # Add missing netmask info for single ips. |
326 | - if '/' not in network: |
327 | - network += '/32' |
328 | - security_group.authorize('tcp', 80, 80, network) |
329 | - security_group.authorize('tcp', 443, 443, network) |
330 | - return security_group |
331 | - |
332 | - def acquire_private_key(self): |
333 | - """Create & return a new key pair for the test runner.""" |
334 | - key_pair = self.conn.create_key_pair(self.name) |
335 | - return paramiko.RSAKey.from_private_key( |
336 | - cStringIO.StringIO(key_pair.material.encode('ascii'))) |
337 | - |
338 | - def delete_previous_key_pair(self): |
339 | - """Delete previously used keypair, if it exists.""" |
340 | - try: |
341 | - # Only one keypair will match 'self.name' since it's a unique |
342 | - # identifier. |
343 | - key_pairs = self.conn.get_all_key_pairs(self.name) |
344 | - assert len(key_pairs) == 1, ( |
345 | - "Should be only one keypair, found %d (%s)" |
346 | - % (len(key_pairs), key_pairs)) |
347 | - key_pair = key_pairs[0] |
348 | - key_pair.delete() |
349 | - except EC2ResponseError, e: |
350 | - if e.code != 'InvalidKeyPair.NotFound': |
351 | - if e.code == 'AuthFailure': |
352 | - # Inserted because of previous support issue. |
353 | - self.log( |
354 | - 'POSSIBLE CAUSES OF ERROR:\n' |
355 | - ' Did you sign up for EC2?\n' |
356 | - ' Did you put a credit card number in your AWS ' |
357 | - 'account?\n' |
358 | - 'Please doublecheck before reporting a problem.\n') |
359 | - raise |
360 | - |
361 | - def acquire_image(self, machine_id): |
362 | - """Get the image. |
363 | - |
364 | - If 'machine_id' is None, then return the image with location that |
365 | - matches `EC2Account._image_match` and has the highest revision number |
366 | - (where revision number is the 'NN' in 'launchpad-ec2testNN'). |
367 | - |
368 | - Otherwise, just return the image with the given 'machine_id'. |
369 | - |
370 | - :raise ValueError: if there is more than one image with the same |
371 | - location string. |
372 | - |
373 | - :raise RuntimeError: if we cannot find a test-runner image. |
374 | - |
375 | - :return: A boto image. |
376 | - """ |
377 | - if machine_id is not None: |
378 | - # This may raise an exception. The user specified a machine_id, so |
379 | - # they can deal with it. |
380 | - return self.conn.get_image(machine_id) |
381 | - |
382 | - # We are trying to find an image that has a location that matches a |
383 | - # regex (see definition of _image_match, above). Part of that regex is |
384 | - # expected to be an integer with the semantics of a revision number. |
385 | - # The image location with the highest revision number is the one that |
386 | - # should be chosen. Because AWS does not guarantee that two images |
387 | - # cannot share a location string, we need to make sure that the search |
388 | - # result for this image is unique, or throw an error because the |
389 | - # choice of image is ambiguous. |
390 | - search_results = None |
391 | - |
392 | - # Find the images with the highest revision numbers and locations that |
393 | - # match the regex. |
394 | - for image in self.conn.get_all_images(owners=VALID_AMI_OWNERS): |
395 | - match = self._image_match(image.location) |
396 | - if match: |
397 | - revision = int(match.group(1)) |
398 | - if (search_results is None |
399 | - or search_results['revision'] < revision): |
400 | - # Then we have our first, highest match. |
401 | - search_results = {'revision': revision, 'images': [image]} |
402 | - elif search_results['revision'] == revision: |
403 | - # Another image that matches and is equally high. |
404 | - search_results['images'].append(image) |
405 | - |
406 | - # No matching image. |
407 | - if search_results is None: |
408 | - raise RuntimeError( |
409 | - "You don't have access to a test-runner image.\n" |
410 | - "Request access and try again.\n") |
411 | - |
412 | - # More than one matching image. |
413 | - if len(search_results['images']) > 1: |
414 | - raise ValueError( |
415 | - ('more than one image of revision %(revision)d found: ' |
416 | - '%(images)r') % search_results) |
417 | - |
418 | - # We could put a minimum image version number check here. |
419 | - image = search_results['images'][0] |
420 | - self.log( |
421 | - 'Using machine image version %d\n' |
422 | - % (search_results['revision'],)) |
423 | - return image |
424 | - |
425 | - def get_instance(self, instance_id): |
426 | - """Look in all of our reservations for an instance with the given ID. |
427 | - |
428 | - Return the instance object if it exists, None otherwise. |
429 | - """ |
430 | - # XXX mars 20090729 |
431 | - # This method is needed by the ec2-generate-windmill-image.py script, |
432 | - # so please do not delete it. |
433 | - # |
434 | - # This is a strange object on which to put this method, but I did |
435 | - # not want to break encapsulation around the self.conn attribute. |
436 | - |
437 | - for reservation in self.conn.get_all_instances(): |
438 | - # We need to look inside each reservation for the instances |
439 | - # themselves. |
440 | - for instance in reservation.instances: |
441 | - if instance.id == instance_id: |
442 | - return instance |
443 | - return None |
444 | - |
445 | - |
446 | -class UnknownBranchURL(Exception): |
447 | - """Raised when we try to parse an unrecognized branch url.""" |
448 | - |
449 | - def __init__(self, branch_url): |
450 | - Exception.__init__( |
451 | - self, |
452 | - "Couldn't parse '%s', not a Launchpad branch." % (branch_url,)) |
453 | - |
454 | - |
455 | -def parse_branch_url(branch_url): |
456 | - """Given the URL of a branch, return its components in a dict.""" |
457 | - _lp_match = re.compile( |
458 | - r'lp:\~([^/]+)/([^/]+)/([^/]+)$').match |
459 | - _bazaar_match = re.compile( |
460 | - r'bzr+ssh://bazaar.launchpad.net/\~([^/]+)/([^/]+)/([^/]+)$').match |
461 | - match = _lp_match(branch_url) |
462 | - if match is None: |
463 | - match = _bazaar_match(branch_url) |
464 | - if match is None: |
465 | - raise UnknownBranchURL(branch_url) |
466 | - owner = match.group(1) |
467 | - product = match.group(2) |
468 | - branch = match.group(3) |
469 | - unique_name = '~%s/%s/%s' % (owner, product, branch) |
470 | - url = 'bzr+ssh://bazaar.launchpad.net/%s' % (unique_name,) |
471 | - return dict( |
472 | - owner=owner, product=product, branch=branch, unique_name=unique_name, |
473 | - url=url) |
474 | - |
475 | - |
476 | -def validate_file(filename): |
477 | - """Raise an error if 'filename' is not a file we can write to.""" |
478 | - if filename is None: |
479 | - return |
480 | - |
481 | - check_file = filename |
482 | - if os.path.exists(check_file): |
483 | - if not os.path.isfile(check_file): |
484 | - raise ValueError( |
485 | - 'file argument %s exists and is not a file' % (filename,)) |
486 | - else: |
487 | - check_file = os.path.dirname(check_file) |
488 | - if (not os.path.exists(check_file) or |
489 | - not os.path.isdir(check_file)): |
490 | - raise ValueError( |
491 | - 'file %s cannot be created.' % (filename,)) |
492 | - if not os.access(check_file, os.W_OK): |
493 | - raise ValueError( |
494 | - 'you do not have permission to write %s' % (filename,)) |
495 | - |
496 | - |
497 | -def normalize_branch_input(data): |
498 | - """Given 'data' return a ('dest', 'src') pair. |
499 | - |
500 | - :param data: One of:: |
501 | - - a double of (sourcecode_location, branch_url). |
502 | - If 'sourcecode_location' is Launchpad, then 'branch_url' can |
503 | - also be the name of a branch of launchpad owned by |
504 | - launchpad-pqm. |
505 | - - a singleton of (branch_url,) |
506 | - - a singleton of (sourcecode_location,) where |
507 | - sourcecode_location corresponds to a Launchpad upstream |
508 | - project as well as a rocketfuel sourcecode location. |
509 | - - a string which could populate any of the above singletons. |
510 | - |
511 | - :return: ('dest', 'src') where 'dest' is the destination |
512 | - sourcecode location in the rocketfuel tree and 'src' is the |
513 | - URL of the branch to put there. The URL can be either a bzr+ssh |
514 | - URL or the name of a branch of launchpad owned by launchpad-pqm. |
515 | - """ |
516 | - # XXX: JonathanLange 2009-06-05: Should convert lp: URL branches to |
517 | - # bzr+ssh:// branches. |
518 | - if isinstance(data, basestring): |
519 | - data = (data,) |
520 | - if len(data) == 2: |
521 | - # Already in dest, src format. |
522 | - return data |
523 | - if len(data) != 1: |
524 | - raise ValueError( |
525 | - 'invalid argument for ``branches`` argument: %r' % |
526 | - (data,)) |
527 | - branch_location = data[0] |
528 | - try: |
529 | - parsed_url = parse_branch_url(branch_location) |
530 | - except UnknownBranchURL: |
531 | - return branch_location, 'lp:%s' % (branch_location,) |
532 | - return parsed_url['product'], parsed_url['url'] |
533 | - |
534 | - |
535 | -def parse_specified_branches(branches): |
536 | - """Given 'branches' from the command line, return a sanitized dict. |
537 | - |
538 | - The dict maps sourcecode locations to branch URLs, according to the |
539 | - rules in `normalize_branch_input`. |
540 | - """ |
541 | - return dict(map(normalize_branch_input, branches)) |
542 | - |
543 | - |
544 | -class EC2Instance: |
545 | - """A single EC2 instance.""" |
546 | - |
547 | - # XXX: JonathanLange 2009-05-31: Make it so that we pass one of these to |
548 | - # EC2 test runner, rather than the test runner knowing how to make one. |
549 | - # Right now, the test runner makes one of these directly. Instead, we want |
550 | - # to make an EC2Account and ask it for one of these instances and then |
551 | - # pass it to the test runner on construction. |
552 | - |
553 | - # XXX: JonathanLange 2009-05-31: Separate out demo server maybe? |
554 | - |
555 | - # XXX: JonathanLange 2009-05-31: Possibly separate out "get an instance" |
556 | - # and "set up instance for Launchpad testing" logic. |
557 | - |
558 | - def __init__(self, name, image, instance_type, demo_networks, controller, |
559 | - vals): |
560 | - self._name = name |
561 | - self._image = image |
562 | - self._controller = controller |
563 | - self._instance_type = instance_type |
564 | - self._demo_networks = demo_networks |
565 | - self._boto_instance = None |
566 | - self._vals = vals |
567 | - |
568 | - def error_and_quit(self, msg): |
569 | - """Print error message and exit.""" |
570 | - sys.stderr.write(msg) |
571 | - sys.exit(1) |
572 | - |
573 | - def log(self, msg): |
574 | - """Log a message on stdout, flushing afterwards.""" |
575 | - # XXX: JonathanLange 2009-05-31 bug=383076: Should delete this and use |
576 | - # Python logging module instead. |
577 | - sys.stdout.write(msg) |
578 | - sys.stdout.flush() |
579 | - |
580 | - def start(self): |
581 | - """Start the instance.""" |
582 | - if self._boto_instance is not None: |
583 | - self.log('Instance %s already started' % self._boto_instance.id) |
584 | - return |
585 | - start = time.time() |
586 | - self.private_key = self._controller.acquire_private_key() |
587 | - self._controller.acquire_security_group( |
588 | - demo_networks=self._demo_networks) |
589 | - reservation = self._image.run( |
590 | - key_name=self._name, security_groups=[self._name], |
591 | - instance_type=self._instance_type) |
592 | - self._boto_instance = reservation.instances[0] |
593 | - self.log('Instance %s starting..' % self._boto_instance.id) |
594 | - while self._boto_instance.state == 'pending': |
595 | - self.log('.') |
596 | - time.sleep(5) |
597 | - self._boto_instance.update() |
598 | - if self._boto_instance.state == 'running': |
599 | - self.log(' started on %s\n' % self.hostname) |
600 | - elapsed = time.time() - start |
601 | - self.log('Started in %d minutes %d seconds\n' % |
602 | - (elapsed // 60, elapsed % 60)) |
603 | - self._output = self._boto_instance.get_console_output() |
604 | - self.log(self._output.output) |
605 | - else: |
606 | - self.error_and_quit( |
607 | - 'failed to start: %s\n' % self._boto_instance.state) |
608 | - |
609 | - def shutdown(self): |
610 | - """Shut down the instance.""" |
611 | - if self._boto_instance is None: |
612 | - self.log('no instance created\n') |
613 | - return |
614 | - self._boto_instance.update() |
615 | - if self._boto_instance.state not in ('shutting-down', 'terminated'): |
616 | - # terminate instance |
617 | - self._boto_instance.stop() |
618 | - self._boto_instance.update() |
619 | - self.log('instance %s\n' % (self._boto_instance.state,)) |
620 | - |
621 | - @property |
622 | - def hostname(self): |
623 | - if self._boto_instance is None: |
624 | - return None |
625 | - return self._boto_instance.public_dns_name |
626 | - |
627 | - def connect_as_root(self): |
628 | - """Connect to the instance as root. |
629 | - |
630 | - All subsequent 'perform' and 'subprocess' operations will be done with |
631 | - root privileges. |
632 | - """ |
633 | - # XXX: JonathanLange 2009-06-02: This state-changing method could |
634 | - # perhaps be written as a function such as run_as_root, or as a method |
635 | - # that returns a root connection. |
636 | - for count in range(10): |
637 | - self.ssh = paramiko.SSHClient() |
638 | - self.ssh.set_missing_host_key_policy(AcceptAllPolicy()) |
639 | - self.username = 'root' |
640 | - try: |
641 | - self.ssh.connect( |
642 | - self.hostname, username='root', |
643 | - pkey=self.private_key, |
644 | - allow_agent=False, look_for_keys=False) |
645 | - except (socket.error, paramiko.AuthenticationException), e: |
646 | - self.log('connect_as_root: %r' % (e,)) |
647 | - if count < 9: |
648 | - time.sleep(5) |
649 | - self.log('retrying...') |
650 | - else: |
651 | - raise |
652 | - else: |
653 | - break |
654 | - |
655 | - def connect_as_user(self): |
656 | - """Connect as user. |
657 | - |
658 | - All subsequent 'perform' and 'subprocess' operations will be done with |
659 | - user-level privileges. |
660 | - """ |
661 | - # XXX: JonathanLange 2009-06-02: This state-changing method could |
662 | - # perhaps be written as a function such as run_as_user, or as a method |
663 | - # that returns a user connection. |
664 | - # |
665 | - # This does not have the retry logic of connect_as_root because the |
666 | - # circumstances that make the retries necessary appear to only happen |
667 | - # on start-up, and connect_as_root is called first. |
668 | - self.ssh = paramiko.SSHClient() |
669 | - self.ssh.set_missing_host_key_policy(AcceptAllPolicy()) |
670 | - self.username = self._vals['USER'] |
671 | - self.ssh.connect(self.hostname) |
672 | - |
673 | - def perform(self, cmd, ignore_failure=False, out=None): |
674 | - """Perform 'cmd' on server. |
675 | - |
676 | - :param ignore_failure: If False, raise an error on non-zero exit |
677 | - statuses. |
678 | - :param out: A stream to write the output of the remote command to. |
679 | - """ |
680 | - cmd = cmd % self._vals |
681 | - self.log('%s@%s$ %s\n' % (self.username, self._boto_instance.id, cmd)) |
682 | - session = self.ssh.get_transport().open_session() |
683 | - session.exec_command(cmd) |
684 | - session.shutdown_write() |
685 | - while 1: |
686 | - select.select([session], [], [], 0.5) |
687 | - if session.recv_ready(): |
688 | - data = session.recv(4096) |
689 | - if data: |
690 | - sys.stdout.write(data) |
691 | - sys.stdout.flush() |
692 | - if out is not None: |
693 | - out.write(data) |
694 | - if session.recv_stderr_ready(): |
695 | - data = session.recv_stderr(4096) |
696 | - if data: |
697 | - sys.stderr.write(data) |
698 | - sys.stderr.flush() |
699 | - if session.exit_status_ready(): |
700 | - break |
701 | - session.close() |
702 | - # XXX: JonathanLange 2009-05-31: If the command is killed by a signal |
703 | - # on the remote server, the SSH protocol does not send an exit_status, |
704 | - # it instead sends a different message with the number of the signal |
705 | - # that killed the process. AIUI, this code will fail confusingly if |
706 | - # that happens. |
707 | - res = session.recv_exit_status() |
708 | - if res and not ignore_failure: |
709 | - raise RuntimeError('Command failed: %s' % (cmd,)) |
710 | - return res |
711 | - |
712 | - def run_with_ssh_agent(self, cmd, ignore_failure=False): |
713 | - """Run 'cmd' in a subprocess. |
714 | - |
715 | - Use this to run commands that require local SSH credentials. For |
716 | - example, getting private branches from Launchpad. |
717 | - """ |
718 | - cmd = cmd % self._vals |
719 | - self.log('%s@%s$ %s\n' % (self.username, self._boto_instance.id, cmd)) |
720 | - call = ['ssh', '-A', self.hostname, |
721 | - '-o', 'CheckHostIP no', |
722 | - '-o', 'StrictHostKeyChecking no', |
723 | - '-o', 'UserKnownHostsFile ~/.ec2/known_hosts', |
724 | - cmd] |
725 | - res = subprocess.call(call) |
726 | - if res and not ignore_failure: |
727 | - raise RuntimeError('Command failed: %s' % (cmd,)) |
728 | - return res |
729 | - |
730 | - |
731 | -class EC2TestRunner: |
732 | - |
733 | - name = 'ec2-test-runner' |
734 | - |
735 | - message = instance = image = None |
736 | - _running = False |
737 | - |
738 | - def __init__(self, branch, email=False, file=None, test_options='-vv', |
739 | - headless=False, branches=(), |
740 | - machine_id=None, instance_type=DEFAULT_INSTANCE_TYPE, |
741 | - pqm_message=None, pqm_public_location=None, |
742 | - pqm_submit_location=None, demo_networks=None, |
743 | - open_browser=False, pqm_email=None, |
744 | - include_download_cache_changes=None): |
745 | - """Create a new EC2TestRunner. |
746 | - |
747 | - This sets the following attributes: |
748 | - - original_branch |
749 | - - test_options |
750 | - - headless |
751 | - - include_download_cache_changes |
752 | - - download_cache_additions |
753 | - - branches (parses, validates) |
754 | - - message (after validating PQM submisson) |
755 | - - email (after validating email capabilities) |
756 | - - instance_type (validates) |
757 | - - image (after connecting to ec2) |
758 | - - file (after checking we can write to it) |
759 | - - ssh_config_file_name (after checking it exists) |
760 | - - vals, a dict containing |
761 | - - the environment |
762 | - - trunk_branch (either from global or derived from branches) |
763 | - - branch |
764 | - - smtp_server |
765 | - - smtp_username |
766 | - - smtp_password |
767 | - - email (distinct from the email attribute) |
768 | - - key_type |
769 | - - key |
770 | - - launchpad_login |
771 | - """ |
772 | - self.original_branch = branch # just for easy access in debugging |
773 | - self.test_options = test_options |
774 | - self.headless = headless |
775 | - self.include_download_cache_changes = include_download_cache_changes |
776 | - if demo_networks is None: |
777 | - demo_networks = () |
778 | - else: |
779 | - demo_networks = demo_networks |
780 | - self.open_browser = open_browser |
781 | - if headless and file: |
782 | - raise ValueError( |
783 | - 'currently do not support files with headless mode.') |
784 | - if headless and not (email or pqm_message): |
785 | - raise ValueError('You have specified no way to get the results ' |
786 | - 'of your headless test run.') |
787 | - |
788 | - if test_options != '-vv' and pqm_message is not None: |
789 | - raise ValueError( |
790 | - "Submitting to PQM with non-default test options isn't " |
791 | - "supported") |
792 | - |
793 | - trunk_specified = False |
794 | - trunk_branch = TRUNK_BRANCH |
795 | - |
796 | - # normalize and validate branches |
797 | - branches = parse_specified_branches(branches) |
798 | - try: |
799 | - launchpad_url = branches.pop('launchpad') |
800 | - except KeyError: |
801 | - # No Launchpad branch specified. |
802 | - pass |
803 | - else: |
804 | - try: |
805 | - parsed_url = parse_branch_url(launchpad_url) |
806 | - except UnknownBranchURL: |
807 | - user = 'launchpad-pqm' |
808 | - src = ('bzr+ssh://bazaar.launchpad.net/' |
809 | - '~launchpad-pqm/launchpad/%s' % (launchpad_url,)) |
810 | - else: |
811 | - user = parsed_url['owner'] |
812 | - src = parsed_url['url'] |
813 | - if user == 'launchpad-pqm': |
814 | - trunk_specified = True |
815 | - trunk_branch = src |
816 | - |
817 | - self.branches = branches.items() |
818 | - |
819 | - # XXX: JonathanLange 2009-05-31: The trunk_specified stuff above and |
820 | - # the pqm location stuff below are actually doing the equivalent of |
821 | - # preparing a merge directive. Perhaps we can leverage that to make |
822 | - # this code simpler. |
823 | - self.download_cache_additions = None |
824 | - if branch is None: |
825 | - config = GlobalConfig() |
826 | - if pqm_message is not None: |
827 | - raise ValueError('Cannot submit trunk to pqm.') |
828 | - else: |
829 | - (tree, |
830 | - bzrbranch, |
831 | - relpath) = BzrDir.open_containing_tree_or_branch(branch) |
832 | - # if tree is None, remote...I'm assuming. |
833 | - if tree is None: |
834 | - config = GlobalConfig() |
835 | - else: |
836 | - config = bzrbranch.get_config() |
837 | - |
838 | - if pqm_message is not None or tree is not None: |
839 | - # if we are going to maybe send a pqm_message, we're going to |
840 | - # go down this path. Also, even if we are not but this is a |
841 | - # local branch, we're going to use the PQM machinery to make |
842 | - # sure that the local branch has been made public, and has all |
843 | - # working changes there. |
844 | - if tree is None: |
845 | - # remote. We will make some assumptions. |
846 | - if pqm_public_location is None: |
847 | - pqm_public_location = branch |
848 | - if pqm_submit_location is None: |
849 | - pqm_submit_location = trunk_branch |
850 | - elif pqm_submit_location is None and trunk_specified: |
851 | - pqm_submit_location = trunk_branch |
852 | - # modified from pqm_submit.py |
853 | - submission = PQMSubmission( |
854 | - source_branch=bzrbranch, |
855 | - public_location=pqm_public_location, |
856 | - message=pqm_message or '', |
857 | - submit_location=pqm_submit_location, |
858 | - tree=tree) |
859 | - if tree is not None: |
860 | - # this is the part we want to do whether or not we're |
861 | - # submitting. |
862 | - submission.check_tree() # any working changes |
863 | - submission.check_public_branch() # everything public |
864 | - branch = submission.public_location |
865 | - if (include_download_cache_changes is None or |
866 | - include_download_cache_changes): |
867 | - # We need to get the download cache settings |
868 | - cache_tree, cache_bzrbranch, cache_relpath = ( |
869 | - BzrDir.open_containing_tree_or_branch( |
870 | - os.path.join( |
871 | - self.original_branch, 'download-cache'))) |
872 | - cache_tree.lock_read() |
873 | - try: |
874 | - cache_basis_tree = cache_tree.basis_tree() |
875 | - cache_basis_tree.lock_read() |
876 | - try: |
877 | - delta = cache_tree.changes_from( |
878 | - cache_basis_tree, want_unversioned=True) |
879 | - unversioned = [ |
880 | - un for un in delta.unversioned |
881 | - if not cache_tree.is_ignored(un[0])] |
882 | - added = delta.added |
883 | - self.download_cache_additions = ( |
884 | - unversioned + added) |
885 | - finally: |
886 | - cache_basis_tree.unlock() |
887 | - finally: |
888 | - cache_tree.unlock() |
889 | - if pqm_message is not None: |
890 | - if self.download_cache_additions: |
891 | - raise UncommittedChanges(cache_tree) |
892 | - # get the submission message |
893 | - mail_from = config.get_user_option('pqm_user_email') |
894 | - if not mail_from: |
895 | - mail_from = config.username() |
896 | - # Make sure this isn't unicode |
897 | - mail_from = mail_from.encode('utf8') |
898 | - if pqm_email is None: |
899 | - if tree is None: |
900 | - pqm_email = ( |
901 | - "Launchpad PQM <launchpad@pqm.canonical.com>") |
902 | - else: |
903 | - pqm_email = config.get_user_option('pqm_email') |
904 | - if not pqm_email: |
905 | - raise NoPQMSubmissionAddress(bzrbranch) |
906 | - mail_to = pqm_email.encode('utf8') # same here |
907 | - self.message = submission.to_email(mail_from, mail_to) |
908 | - elif (self.download_cache_additions and |
909 | - self.include_download_cache_changes is None): |
910 | - raise UncommittedChanges( |
911 | - cache_tree, |
912 | - 'You must select whether to include download cache ' |
913 | - 'changes (see --include-download-cache-changes and ' |
914 | - '--ignore-download-cache-changes, -c and -g ' |
915 | - 'respectively), or ' |
916 | - 'commit or remove the files in the download-cache.') |
917 | - if email is not False: |
918 | - if email is True: |
919 | - email = [config.username()] |
920 | - if not email[0]: |
921 | - raise ValueError('cannot find your email address.') |
922 | - elif isinstance(email, basestring): |
923 | - email = [email] |
924 | - else: |
925 | - tmp = [] |
926 | - for item in email: |
927 | - if not isinstance(item, basestring): |
928 | - raise ValueError( |
929 | - 'email must be True, False, a string, or a list of ' |
930 | - 'strings') |
931 | - tmp.append(item) |
932 | - email = tmp |
933 | - else: |
934 | - email = None |
935 | - self.email = email |
936 | - |
937 | - # We do a lot of looking before leaping here because we want to avoid |
938 | - # wasting time and money on errors we could have caught early. |
939 | - |
940 | - # Validate instance_type and get default kernal and ramdisk. |
941 | - if instance_type not in AVAILABLE_INSTANCE_TYPES: |
942 | - raise ValueError('unknown instance_type %s' % (instance_type,)) |
943 | - |
944 | - # Validate and set file. |
945 | - validate_file(file) |
946 | - self.file = file |
947 | - |
948 | - # Make a dict for string substitution based on the environ. |
949 | - # |
950 | - # XXX: JonathanLange 2009-06-02: Although this defintely makes the |
951 | - # scripts & commands easier to write, it makes it harder to figure out |
952 | - # how the different bits of the system interoperate (passing 'vals' to |
953 | - # a method means it uses...?). Consider changing things around so that |
954 | - # vals is not needed. |
955 | - self.vals = dict(os.environ) |
956 | - self.vals['trunk_branch'] = trunk_branch |
957 | - self.vals['branch'] = branch |
958 | - home = self.vals['HOME'] |
959 | - |
960 | - # Email configuration. |
961 | - if email is not None or pqm_message is not None: |
962 | - server = self.vals['smtp_server'] = config.get_user_option( |
963 | - 'smtp_server') |
964 | - if server is None or server == 'localhost': |
965 | - raise ValueError( |
966 | - 'To send email, a remotely accessible smtp_server (and ' |
967 | - 'smtp_username and smtp_password, if necessary) must be ' |
968 | - 'configured in bzr. See the SMTP server information ' |
969 | - 'here: https://wiki.canonical.com/EmailSetup .') |
970 | - self.vals['smtp_username'] = config.get_user_option( |
971 | - 'smtp_username') |
972 | - self.vals['smtp_password'] = config.get_user_option( |
973 | - 'smtp_password') |
974 | - from_email = config.username() |
975 | - if not from_email: |
976 | - raise ValueError( |
977 | - 'To send email, your bzr email address must be set ' |
978 | - '(use ``bzr whoami``).') |
979 | - else: |
980 | - self.vals['email'] = ( |
981 | - from_email.encode('utf8').encode('string-escape')) |
982 | - |
983 | - # Get a public key from the agent. |
984 | - agent = paramiko.Agent() |
985 | - keys = agent.get_keys() |
986 | - if len(keys) == 0: |
987 | - self.error_and_quit( |
988 | - 'You must have an ssh agent running with keys installed that ' |
989 | - 'will allow the script to rsync to devpad and get your ' |
990 | - 'branch.\n') |
991 | - key = agent.get_keys()[0] |
992 | - self.vals['key_type'] = key.get_name() |
993 | - self.vals['key'] = key.get_base64() |
994 | - |
995 | - # Verify the .ssh config file |
996 | - self.ssh_config_file_name = os.path.join(home, '.ssh', 'config') |
997 | - if not os.path.exists(self.ssh_config_file_name): |
998 | - self.error_and_quit( |
999 | - 'This script expects to find the .ssh config in %s. Please ' |
1000 | - 'make sure it exists and contains the necessary ' |
1001 | - 'configuration to access devpad.' % ( |
1002 | - self.ssh_config_file_name,)) |
1003 | - |
1004 | - # Get the bzr login. |
1005 | - login = get_lp_login() |
1006 | - if not login: |
1007 | - self.error_and_quit( |
1008 | - 'you must have set your launchpad login in bzr.') |
1009 | - self.vals['launchpad-login'] = login |
1010 | - |
1011 | - # Get the AWS identifier and secret identifier. |
1012 | - try: |
1013 | - credentials = EC2Credentials.load_from_file() |
1014 | - except CredentialsError, e: |
1015 | - self.error_and_quit(str(e)) |
1016 | - |
1017 | - # Make the EC2 connection. |
1018 | - controller = credentials.connect(self.name) |
1019 | - |
1020 | - # We do this here because it (1) cleans things up and (2) verifies |
1021 | - # that the account is correctly set up. Both of these are appropriate |
1022 | - # for initialization. |
1023 | - # |
1024 | - # We always recreate the keypairs because there is no way to |
1025 | - # programmatically retrieve the private key component, unless we |
1026 | - # generate it. |
1027 | - controller.delete_previous_key_pair() |
1028 | - |
1029 | - # get the image |
1030 | - image = controller.acquire_image(machine_id) |
1031 | - self._instance = EC2Instance( |
1032 | - self.name, image, instance_type, demo_networks, |
1033 | - controller, self.vals) |
1034 | - # now, as best as we can tell, we should be good to go. |
1035 | - |
1036 | - def error_and_quit(self, msg): |
1037 | - """Print error message and exit.""" |
1038 | - sys.stderr.write(msg) |
1039 | - sys.exit(1) |
1040 | - |
1041 | - def log(self, msg): |
1042 | - """Log a message on stdout, flushing afterwards.""" |
1043 | - # XXX: JonathanLange 2009-05-31 bug=383076: This should use Python |
1044 | - # logging, rather than printing to stdout. |
1045 | - sys.stdout.write(msg) |
1046 | - sys.stdout.flush() |
1047 | - |
1048 | - def start(self): |
1049 | - """Start the EC2 instance.""" |
1050 | - self._instance.start() |
1051 | - |
1052 | - def shutdown(self): |
1053 | - if self.headless and self._running: |
1054 | - self.log('letting instance run, to shut down headlessly ' |
1055 | - 'at completion of tests.\n') |
1056 | - return |
1057 | - return self._instance.shutdown() |
1058 | - |
1059 | - def configure_system(self): |
1060 | - # AS ROOT |
1061 | - self._instance.connect_as_root() |
1062 | - if self.vals['USER'] == 'gary': |
1063 | - # This helps gary debug problems others are having by removing |
1064 | - # much of the initial setup used to work on the original image. |
1065 | - self._instance.perform('deluser --remove-home gary', |
1066 | - ignore_failure=True) |
1067 | - p = self._instance.perform |
1068 | - # Let root perform sudo without a password. |
1069 | - p('echo "root\tALL=NOPASSWD: ALL" >> /etc/sudoers') |
1070 | - # Add the user. |
1071 | - p('adduser --gecos "" --disabled-password %(USER)s') |
1072 | - # Give user sudo without password. |
1073 | - p('echo "%(USER)s\tALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers') |
1074 | - # Make /var/launchpad owned by user. |
1075 | - p('chown -R %(USER)s:%(USER)s /var/launchpad') |
1076 | - # Clean out left-overs from the instance image. |
1077 | - p('rm -fr /var/tmp/*') |
1078 | - # Update the system. |
1079 | - p('aptitude update') |
1080 | - p('aptitude -y full-upgrade') |
1081 | - # Set up ssh for user |
1082 | - # Make user's .ssh directory |
1083 | - p('sudo -u %(USER)s mkdir /home/%(USER)s/.ssh') |
1084 | - sftp = self._instance.ssh.open_sftp() |
1085 | - remote_ssh_dir = '/home/%(USER)s/.ssh' % self.vals |
1086 | - # Create config file |
1087 | - self.log('Creating %s/config\n' % (remote_ssh_dir,)) |
1088 | - ssh_config_source = open(self.ssh_config_file_name) |
1089 | - config = SSHConfig() |
1090 | - config.parse(ssh_config_source) |
1091 | - ssh_config_source.close() |
1092 | - ssh_config_dest = sftp.open("%s/config" % remote_ssh_dir, 'w') |
1093 | - ssh_config_dest.write('CheckHostIP no\n') |
1094 | - ssh_config_dest.write('StrictHostKeyChecking no\n') |
1095 | - for hostname in ('devpad.canonical.com', 'chinstrap.canonical.com'): |
1096 | - ssh_config_dest.write('Host %s\n' % (hostname,)) |
1097 | - data = config.lookup(hostname) |
1098 | - for key in ('hostname', 'gssapiauthentication', 'proxycommand', |
1099 | - 'user', 'forwardagent'): |
1100 | - value = data.get(key) |
1101 | - if value is not None: |
1102 | - ssh_config_dest.write(' %s %s\n' % (key, value)) |
1103 | - ssh_config_dest.write('Host bazaar.launchpad.net\n') |
1104 | - ssh_config_dest.write(' user %(launchpad-login)s\n' % self.vals) |
1105 | - ssh_config_dest.close() |
1106 | - # create authorized_keys |
1107 | - self.log('Setting up %s/authorized_keys\n' % remote_ssh_dir) |
1108 | - authorized_keys_file = sftp.open( |
1109 | - "%s/authorized_keys" % remote_ssh_dir, 'w') |
1110 | - authorized_keys_file.write("%(key_type)s %(key)s\n" % self.vals) |
1111 | - authorized_keys_file.close() |
1112 | - sftp.close() |
1113 | - # Chown and chmod the .ssh directory and contents that we just |
1114 | - # created. |
1115 | - p('chown -R %(USER)s:%(USER)s /home/%(USER)s/') |
1116 | - p('chmod 644 /home/%(USER)s/.ssh/*') |
1117 | - self.log( |
1118 | - 'You can now use ssh -A %s to log in the instance.\n' % |
1119 | - self._instance.hostname) |
1120 | - # give the user permission to do whatever in /var/www |
1121 | - p('chown -R %(USER)s:%(USER)s /var/www') |
1122 | - self._instance.ssh.close() |
1123 | - |
1124 | - # AS USER |
1125 | - self._instance.connect_as_user() |
1126 | - sftp = self._instance.ssh.open_sftp() |
1127 | - # Set up bazaar.conf with smtp information if necessary |
1128 | - if self.email or self.message: |
1129 | - p('sudo -u %(USER)s mkdir /home/%(USER)s/.bazaar') |
1130 | - bazaar_conf_file = sftp.open( |
1131 | - "/home/%(USER)s/.bazaar/bazaar.conf" % self.vals, 'w') |
1132 | - bazaar_conf_file.write( |
1133 | - 'smtp_server = %(smtp_server)s\n' % self.vals) |
1134 | - if self.vals['smtp_username']: |
1135 | - bazaar_conf_file.write( |
1136 | - 'smtp_username = %(smtp_username)s\n' % self.vals) |
1137 | - if self.vals['smtp_password']: |
1138 | - bazaar_conf_file.write( |
1139 | - 'smtp_password = %(smtp_password)s\n' % self.vals) |
1140 | - bazaar_conf_file.close() |
1141 | - # Copy remote ec2-remote over |
1142 | - self.log('Copying ec2test-remote.py to remote machine.\n') |
1143 | - sftp.put( |
1144 | - os.path.join(os.path.dirname(os.path.realpath(__file__)), |
1145 | - 'ec2test-remote.py'), |
1146 | - '/var/launchpad/ec2test-remote.py') |
1147 | - sftp.close() |
1148 | - # Set up launchpad login and email |
1149 | - p('bzr launchpad-login %(launchpad-login)s') |
1150 | - p("bzr whoami '%(email)s'") |
1151 | - self._instance.ssh.close() |
1152 | - |
1153 | - def prepare_tests(self): |
1154 | - self._instance.connect_as_user() |
1155 | - # Clean up the test branch left in the instance image. |
1156 | - self._instance.perform('rm -rf /var/launchpad/test') |
1157 | - # get newest sources |
1158 | - self._instance.run_with_ssh_agent( |
1159 | - "rsync -avp --partial --delete " |
1160 | - "--filter='P *.o' --filter='P *.pyc' --filter='P *.so' " |
1161 | - "devpad.canonical.com:/code/rocketfuel-built/launchpad/sourcecode/* " |
1162 | - "/var/launchpad/sourcecode/") |
1163 | - # Get trunk. |
1164 | - self._instance.run_with_ssh_agent( |
1165 | - 'bzr branch %(trunk_branch)s /var/launchpad/test') |
1166 | - # Merge the branch in. |
1167 | - if self.vals['branch'] is not None: |
1168 | - self._instance.run_with_ssh_agent( |
1169 | - 'cd /var/launchpad/test; bzr merge %(branch)s') |
1170 | - else: |
1171 | - self.log('(Testing trunk, so no branch merge.)') |
1172 | - # Get any new sourcecode branches as requested |
1173 | - for dest, src in self.branches: |
1174 | - fulldest = os.path.join('/var/launchpad/test/sourcecode', dest) |
1175 | - if dest in ('canonical-identity-provider', 'shipit'): |
1176 | - # These two branches share some of the history with Launchpad. |
1177 | - # So we create a stacked branch on Launchpad so that the shared |
1178 | - # history isn't duplicated. |
1179 | - self._instance.run_with_ssh_agent( |
1180 | - 'bzr branch --no-tree --stacked %s %s' % |
1181 | - (TRUNK_BRANCH, fulldest)) |
1182 | - # The --overwrite is needed because they are actually two |
1183 | - # different branches (canonical-identity-provider was not |
1184 | - # branched off launchpad, but some revisions are shared.) |
1185 | - self._instance.run_with_ssh_agent( |
1186 | - 'bzr pull --overwrite %s -d %s' % (src, fulldest)) |
1187 | - # The third line is necessary because of the --no-tree option |
1188 | - # used initially. --no-tree doesn't create a working tree. |
1189 | - # It only works with the .bzr directory (branch metadata and |
1190 | - # revisions history). The third line creates a working tree |
1191 | - # based on the actual branch. |
1192 | - self._instance.run_with_ssh_agent( |
1193 | - 'bzr checkout "%s" "%s"' % (fulldest, fulldest)) |
1194 | - else: |
1195 | - # The "--standalone" option is needed because some branches |
1196 | - # are/were using a different repository format than Launchpad |
1197 | - # (bzr-svn branch for example). |
1198 | - self._instance.run_with_ssh_agent( |
1199 | - 'bzr branch --standalone %s %s' % (src, fulldest)) |
1200 | - # prepare fresh copy of sourcecode and buildout sources for building |
1201 | - p = self._instance.perform |
1202 | - p('rm -rf /var/launchpad/tmp') |
1203 | - p('mkdir /var/launchpad/tmp') |
1204 | - p('cp -R /var/launchpad/sourcecode /var/launchpad/tmp/sourcecode') |
1205 | - p('mkdir /var/launchpad/tmp/eggs') |
1206 | - self._instance.run_with_ssh_agent( |
1207 | - 'bzr co lp:lp-source-dependencies ' |
1208 | - '/var/launchpad/tmp/download-cache') |
1209 | - if (self.include_download_cache_changes and |
1210 | - self.download_cache_additions): |
1211 | - sftp = self._instance.ssh.open_sftp() |
1212 | - root = os.path.realpath( |
1213 | - os.path.join(self.original_branch, 'download-cache')) |
1214 | - for info in self.download_cache_additions: |
1215 | - src = os.path.join(root, info[0]) |
1216 | - self.log('Copying %s to remote machine.\n' % (src,)) |
1217 | - sftp.put( |
1218 | - src, |
1219 | - os.path.join('/var/launchpad/tmp/download-cache', info[0])) |
1220 | - sftp.close() |
1221 | - p('/var/launchpad/test/utilities/link-external-sourcecode ' |
1222 | - '-p/var/launchpad/tmp -t/var/launchpad/test'), |
1223 | - # set up database |
1224 | - p('/var/launchpad/test/utilities/launchpad-database-setup %(USER)s') |
1225 | - p('cd /var/launchpad/test && make build') |
1226 | - p('cd /var/launchpad/test && make schema') |
1227 | - # close ssh connection |
1228 | - self._instance.ssh.close() |
1229 | - |
1230 | - def start_demo_webserver(self): |
1231 | - """Turn ec2 instance into a demo server.""" |
1232 | - self._instance.connect_as_user() |
1233 | - p = self._instance.perform |
1234 | - p('mkdir -p /var/tmp/bazaar.launchpad.dev/static') |
1235 | - p('mkdir -p /var/tmp/bazaar.launchpad.dev/mirrors') |
1236 | - p('sudo a2enmod proxy > /dev/null') |
1237 | - p('sudo a2enmod proxy_http > /dev/null') |
1238 | - p('sudo a2enmod rewrite > /dev/null') |
1239 | - p('sudo a2enmod ssl > /dev/null') |
1240 | - p('sudo a2enmod deflate > /dev/null') |
1241 | - p('sudo a2enmod headers > /dev/null') |
1242 | - # Install apache config file. |
1243 | - p('cd /var/launchpad/test/; sudo make install') |
1244 | - # Use raw string to eliminate the need to escape the backslash. |
1245 | - # Put eth0's ip address in the /tmp/ip file. |
1246 | - p(r"ifconfig eth0 | grep 'inet addr' " |
1247 | - r"| sed -re 's/.*addr:([0-9.]*) .*/\1/' > /tmp/ip") |
1248 | - # Replace 127.0.0.88 in Launchpad's apache config file with the |
1249 | - # ip address just stored in the /tmp/ip file. Perl allows for |
1250 | - # inplace editing unlike sed. |
1251 | - p('sudo perl -pi -e "s/127.0.0.88/$(cat /tmp/ip)/g" ' |
1252 | - '/etc/apache2/sites-available/local-launchpad') |
1253 | - # Restart apache. |
1254 | - p('sudo /etc/init.d/apache2 restart') |
1255 | - # Build mailman and minified javascript, etc. |
1256 | - p('cd /var/launchpad/test/; make') |
1257 | - # Start launchpad in the background. |
1258 | - p('cd /var/launchpad/test/; make start') |
1259 | - # close ssh connection |
1260 | - self._instance.ssh.close() |
1261 | - |
1262 | - def run_tests(self): |
1263 | - self._instance.connect_as_user() |
1264 | - |
1265 | - # Make sure we activate the failsafe --shutdown feature. This will |
1266 | - # make the server shut itself down after the test run completes, or |
1267 | - # if the test harness suffers a critical failure. |
1268 | - cmd = ['python /var/launchpad/ec2test-remote.py --shutdown'] |
1269 | - |
1270 | - # Do we want to email the results to the user? |
1271 | - if self.email: |
1272 | - for email in self.email: |
1273 | - cmd.append("--email='%s'" % ( |
1274 | - email.encode('utf8').encode('string-escape'),)) |
1275 | - |
1276 | - # Do we want to submit the branch to PQM if the tests pass? |
1277 | - if self.message is not None: |
1278 | - cmd.append( |
1279 | - "--submit-pqm-message='%s'" % ( |
1280 | - pickle.dumps( |
1281 | - self.message).encode( |
1282 | - 'base64').encode('string-escape'),)) |
1283 | - |
1284 | - # Do we want to disconnect the terminal once the test run starts? |
1285 | - if self.headless: |
1286 | - cmd.append('--daemon') |
1287 | - |
1288 | - # Which branch do we want to test? |
1289 | - if self.vals['branch'] is not None: |
1290 | - branch = self.vals['branch'] |
1291 | - remote_branch = Branch.open(branch) |
1292 | - branch_revno = remote_branch.revno() |
1293 | - else: |
1294 | - branch = self.vals['trunk_branch'] |
1295 | - branch_revno = None |
1296 | - cmd.append('--public-branch=%s' % branch) |
1297 | - if branch_revno is not None: |
1298 | - cmd.append('--public-branch-revno=%d' % branch_revno) |
1299 | - |
1300 | - # Add any additional options for ec2test-remote.py |
1301 | - cmd.extend(self.get_remote_test_options()) |
1302 | - self.log( |
1303 | - 'Running tests... (output is available on ' |
1304 | - 'http://%s/)\n' % self._instance.hostname) |
1305 | - |
1306 | - # Try opening a browser pointed at the current test results. |
1307 | - if self.open_browser: |
1308 | - try: |
1309 | - import webbrowser |
1310 | - except ImportError: |
1311 | - self.log("Could not open web browser due to ImportError.") |
1312 | - else: |
1313 | - status = webbrowser.open(self._instance.hostname) |
1314 | - if not status: |
1315 | - self.log("Could not open web browser.") |
1316 | - |
1317 | - # Run the remote script! Our execution will block here until the |
1318 | - # remote side disconnects from the terminal. |
1319 | - self._instance.perform(' '.join(cmd)) |
1320 | - self._running = True |
1321 | - |
1322 | - if not self.headless: |
1323 | - sftp = self._instance.ssh.open_sftp() |
1324 | - # We ran to completion locally, so we'll be in charge of shutting |
1325 | - # down the instance, in case the user has requested a postmortem. |
1326 | - # |
1327 | - # We only have 60 seconds to do this before the remote test |
1328 | - # script shuts the server down automatically. |
1329 | - self._instance.perform( |
1330 | - 'kill `cat /var/launchpad/ec2test-remote.pid`') |
1331 | - |
1332 | - # deliver results as requested |
1333 | - if self.file: |
1334 | - self.log( |
1335 | - 'Writing abridged test results to %s.\n' % self.file) |
1336 | - sftp.get('/var/www/summary.log', self.file) |
1337 | - sftp.close() |
1338 | - # close ssh connection |
1339 | - self._instance.ssh.close() |
1340 | - |
1341 | - def get_remote_test_options(self): |
1342 | - """Return the test command that will be passed to ec2test-remote.py. |
1343 | - |
1344 | - Returns a tuple of command-line options and switches. |
1345 | - """ |
1346 | - if '--jscheck' in self.test_options: |
1347 | - # We want to run the JavaScript test suite. |
1348 | - return ('--jscheck',) |
1349 | - else: |
1350 | - # Run the normal testsuite with our Zope testrunner options. |
1351 | - # ec2test-remote.py wants the extra options to be after a double- |
1352 | - # dash. |
1353 | - return ('--', self.test_options) |
1354 | - |
1355 | - |
1356 | - |
1357 | -class AcceptAllPolicy: |
1358 | - """We accept all unknown host key.""" |
1359 | - |
1360 | - # Normally the console output is supposed to contain the Host key |
1361 | - # but it doesn't seem to be the case here, so we trust that the host |
1362 | - # we are connecting to is the correct one. |
1363 | - def missing_host_key(self, client, hostname, key): |
1364 | - pass |
1365 | - |
1366 | - |
1367 | -# XXX: JonathanLange 2009-05-31: Strongly considering turning this into a |
1368 | -# Bazaar plugin -- probably would make the option parsing and validation |
1369 | -# easier. |
1370 | - |
1371 | -if __name__ == '__main__': |
1372 | - parser = optparse.OptionParser( |
1373 | - usage="%prog [options] [branch]", |
1374 | - description=( |
1375 | - "Check out a Launchpad branch and run all tests on an Amazon " |
1376 | - "EC2 instance.")) |
1377 | - parser.add_option( |
1378 | - '-f', '--file', dest='file', default=None, |
1379 | - help=('Store abridged test results in FILE.')) |
1380 | - parser.add_option( |
1381 | - '-n', '--no-email', dest='no_email', default=False, |
1382 | - action='store_true', |
1383 | - help=('Do not try to email results.')) |
1384 | - parser.add_option( |
1385 | - '-e', '--email', action='append', dest='email', default=None, |
1386 | - help=('Email address to which results should be mailed. Defaults to ' |
1387 | - 'the email address from `bzr whoami`. May be supplied multiple ' |
1388 | - 'times. The first supplied email address will be used as the ' |
1389 | - 'From: address.')) |
1390 | - parser.add_option( |
1391 | - '-o', '--test-options', dest='test_options', default='-vv', |
1392 | - help=('Test options to pass to the remote test runner. Defaults to ' |
1393 | - "``-o '-vv'``. For instance, to run specific tests, you might " |
1394 | - "use ``-o '-vvt my_test_pattern'``.")) |
1395 | - parser.add_option( |
1396 | - '-b', '--branch', action='append', dest='branches', |
1397 | - help=('Branches to include in this run in sourcecode. ' |
1398 | - 'If the argument is only the project name, the trunk will be ' |
1399 | - 'used (e.g., ``-b launchpadlib``). If you want to use a ' |
1400 | - 'specific branch, if it is on launchpad, you can usually ' |
1401 | - 'simply specify it instead (e.g., ' |
1402 | - '``-b lp:~username/launchpadlib/branchname``). If this does ' |
1403 | - 'not appear to work, or if the desired branch is not on ' |
1404 | - 'launchpad, specify the project name and then the branch ' |
1405 | - 'after an equals sign (e.g., ' |
1406 | - '``-b launchpadlib=lp:~username/launchpadlib/branchname``). ' |
1407 | - 'Branches for multiple projects may be specified with ' |
1408 | - 'multiple instances of this option. ' |
1409 | - 'You may also use this option to specify the branch of launchpad ' |
1410 | - 'into which your branch may be merged. This defaults to %s. ' |
1411 | - 'Because typically the important branches of launchpad are owned ' |
1412 | - 'by the launchpad-pqm user, you can shorten this to only the ' |
1413 | - 'branch name, if desired, and the launchpad-pqm user will be ' |
1414 | - 'assumed. For instance, if you specify ' |
1415 | - '``-b launchpad=db-devel`` then this is equivalent to ' |
1416 | - '``-b lp:~launchpad-pqm/launchpad/db-devel``, or the even longer' |
1417 | - '``-b launchpad=lp:~launchpad-pqm/launchpad/db-devel``.' |
1418 | - % (TRUNK_BRANCH,))) |
1419 | - parser.add_option( |
1420 | - '-t', '--trunk', dest='trunk', default=False, |
1421 | - action='store_true', |
1422 | - help=('Run the trunk as the branch')) |
1423 | - parser.add_option( |
1424 | - '-s', '--submit-pqm-message', dest='pqm_message', default=None, |
1425 | - help=('A pqm message to submit if the test run is successful. If ' |
1426 | - 'provided, you will be asked for your GPG passphrase before ' |
1427 | - 'the test run begins.')) |
1428 | - parser.add_option( |
1429 | - '--pqm-public-location', dest='pqm_public_location', default=None, |
1430 | - help=('The public location for the pqm submit, if a pqm message is ' |
1431 | - 'provided (see --submit-pqm-message). If this is not provided, ' |
1432 | - 'for local branches, bzr configuration is consulted; for ' |
1433 | - 'remote branches, it is assumed that the remote branch *is* ' |
1434 | - 'a public branch.')) |
1435 | - parser.add_option( |
1436 | - '--pqm-submit-location', dest='pqm_submit_location', default=None, |
1437 | - help=('The submit location for the pqm submit, if a pqm message is ' |
1438 | - 'provided (see --submit-pqm-message). If this option is not ' |
1439 | - 'provided, the script will look for an explicitly specified ' |
1440 | - 'launchpad branch using the -b/--branch option; if that branch ' |
1441 | - 'was specified and is owned by the launchpad-pqm user on ' |
1442 | - 'launchpad, it is used as the pqm submit location. Otherwise, ' |
1443 | - 'for local branches, bzr configuration is consulted; for ' |
1444 | - 'remote branches, it is assumed that the submit branch is %s.' |
1445 | - % (TRUNK_BRANCH,))) |
1446 | - parser.add_option( |
1447 | - '--pqm-email', dest='pqm_email', default=None, |
1448 | - help=('Specify the email address of the PQM you are submitting to. ' |
1449 | - 'If the branch is local, then the bzr configuration is ' |
1450 | - 'consulted; for remote branches "Launchpad PQM ' |
1451 | - '<launchpad@pqm.canonical.com>" is used by default.')) |
1452 | - parser.add_option( |
1453 | - '-m', '--machine', dest='machine_id', default=None, |
1454 | - help=('The AWS machine identifier (AMID) on which to base this run. ' |
1455 | - 'You should typically only have to supply this if you are ' |
1456 | - 'testing new AWS images. Defaults to trying to find the most ' |
1457 | - 'recent one with an approved owner.')) |
1458 | - parser.add_option( |
1459 | - '-i', '--instance', dest='instance_type', |
1460 | - default=DEFAULT_INSTANCE_TYPE, |
1461 | - help=('The AWS instance type on which to base this run. ' |
1462 | - 'Available options are %r. Defaults to `%s`.' % |
1463 | - (AVAILABLE_INSTANCE_TYPES, DEFAULT_INSTANCE_TYPE))) |
1464 | - parser.add_option( |
1465 | - '-p', '--postmortem', dest='postmortem', default=False, |
1466 | - action='store_true', |
1467 | - help=('Drop to interactive prompt after the test and before shutting ' |
1468 | - 'down the instance for postmortem analysis of the EC2 instance ' |
1469 | - 'and/or of this script.')) |
1470 | - parser.add_option( |
1471 | - '--headless', dest='headless', default=False, |
1472 | - action='store_true', |
1473 | - help=('After building the instance and test, run the remote tests ' |
1474 | - 'headless. Cannot be used with postmortem ' |
1475 | - 'or file.')) |
1476 | - parser.add_option( |
1477 | - '-d', '--debug', dest='debug', default=False, |
1478 | - action='store_true', |
1479 | - help=('Drop to pdb trace as soon as possible.')) |
1480 | - # Use tabs to force a newline in the help text. |
1481 | - fake_newline = "\t\t\t\t\t\t\t" |
1482 | - parser.add_option( |
1483 | - '--demo', action='append', dest='demo_networks', |
1484 | - help=("Don't run tests. Instead start a demo instance of Launchpad. " |
1485 | - "You can allow multiple networks to access the demo by " |
1486 | - "repeating the argument." + fake_newline + |
1487 | - "Example: --demo 192.168.1.100 --demo 10.1.13.0/24" + |
1488 | - fake_newline + |
1489 | - "See" + fake_newline + |
1490 | - "https://wiki.canonical.com/Launchpad/EC2Test/ForDemos" )) |
1491 | - parser.add_option( |
1492 | - '--open-browser', dest='open_browser', default=False, |
1493 | - action='store_true', |
1494 | - help=('Open the results page in your default browser')) |
1495 | - parser.add_option( |
1496 | - '-c', '--include-download-cache-changes', |
1497 | - dest='include_download_cache_changes', action='store_true', |
1498 | - help=('Include any changes in the download cache (added or unknown) ' |
1499 | - 'in the download cache of the test run. Note that, if you have ' |
1500 | - 'any changes in your download cache, trying to submit to pqm ' |
1501 | - 'will always raise an error. Also note that, if you have any ' |
1502 | - 'changes in your download cache, you must explicitly choose to ' |
1503 | - 'include or ignore the changes.')) |
1504 | - parser.add_option( |
1505 | - '-g', '--ignore-download-cache-changes', |
1506 | - dest='include_download_cache_changes', action='store_false', |
1507 | - help=('Ignore any changes in the download cache (added or unknown) ' |
1508 | - 'in the download cache of the test run. Note that, if you have ' |
1509 | - 'any changes in your download cache, trying to submit to pqm ' |
1510 | - 'will always raise an error. Also note that, if you have any ' |
1511 | - 'changes in your download cache, you must explicitly choose to ' |
1512 | - 'include or ignore the changes.')) |
1513 | - options, args = parser.parse_args() |
1514 | - if options.debug: |
1515 | - import pdb; pdb.set_trace() |
1516 | - if options.demo_networks: |
1517 | - # We need the postmortem console to open the ec2 instance's |
1518 | - # network access, and to keep the ec2 instance from being shutdown. |
1519 | - options.postmortem = True |
1520 | - if len(args) == 1: |
1521 | - if options.trunk: |
1522 | - parser.error( |
1523 | - 'Cannot supply both a branch and the --trunk argument.') |
1524 | - branch = args[0] |
1525 | - elif len(args) > 1: |
1526 | - parser.error('Too many arguments.') |
1527 | - elif options.trunk: |
1528 | - branch = None |
1529 | - else: |
1530 | - branch = '.' |
1531 | - if ((options.postmortem or options.file or options.demo_networks) |
1532 | - and options.headless): |
1533 | - parser.error( |
1534 | - 'Headless mode currently does not support postmortem, file ' |
1535 | - 'or demo options.') |
1536 | - if options.no_email: |
1537 | - if options.email: |
1538 | - parser.error( |
1539 | - 'May not supply both --no-email and an --email address') |
1540 | - email = False |
1541 | - else: |
1542 | - email = options.email |
1543 | - if email is None: |
1544 | - email = True |
1545 | - if options.instance_type not in AVAILABLE_INSTANCE_TYPES: |
1546 | - parser.error('Unknown instance type.') |
1547 | - if options.branches is None: |
1548 | - branches = () |
1549 | - else: |
1550 | - branches = [data.split('=', 1) for data in options.branches] |
1551 | - runner = EC2TestRunner( |
1552 | - branch, email=email, file=options.file, |
1553 | - test_options=options.test_options, headless=options.headless, |
1554 | - branches=branches, |
1555 | - machine_id=options.machine_id, instance_type=options.instance_type, |
1556 | - pqm_message=options.pqm_message, |
1557 | - pqm_public_location=options.pqm_public_location, |
1558 | - pqm_submit_location=options.pqm_submit_location, |
1559 | - demo_networks=options.demo_networks, |
1560 | - open_browser=options.open_browser, pqm_email=options.pqm_email, |
1561 | - include_download_cache_changes=options.include_download_cache_changes, |
1562 | - ) |
1563 | - e = None |
1564 | - try: |
1565 | - try: |
1566 | - runner.start() |
1567 | - runner.configure_system() |
1568 | - runner.prepare_tests() |
1569 | - if options.demo_networks: |
1570 | - runner.start_demo_webserver() |
1571 | - else: |
1572 | - result = runner.run_tests() |
1573 | - except Exception, e: |
1574 | - # If we are running in demo or postmortem mode, it is really |
1575 | - # helpful to see if there are any exceptions before it waits |
1576 | - # in the console (in the finally block), and you can't figure |
1577 | - # out why it's broken. |
1578 | - traceback.print_exc() |
1579 | - finally: |
1580 | - try: |
1581 | - # XXX: JonathanLange 2009-06-02: Blackbox alert! This gets at the |
1582 | - # private _instance variable of runner. Instead, it should do |
1583 | - # something smarter. For example, the demo networks stuff could be |
1584 | - # extracted out to different, non-TestRunner class that has an |
1585 | - # instance. |
1586 | - if options.demo_networks and runner._instance is not None: |
1587 | - demo_network_string = '\n'.join( |
1588 | - ' ' + network for network in options.demo_networks) |
1589 | - # XXX: JonathanLange 2009-06-02: Blackbox alert! See above. |
1590 | - ec2_ip = socket.gethostbyname(runner._instance.hostname) |
1591 | - print ( |
1592 | - "\n\n" |
1593 | - "********************** DEMO *************************\n" |
1594 | - "It may take 20 seconds for the demo server to start up." |
1595 | - "\nTo demo to other users, you still need to open up\n" |
1596 | - "network access to the ec2 instance from their IPs by\n" |
1597 | - "entering command like this in the interactive python\n" |
1598 | - "interpreter at the end of the setup. " |
1599 | - "\n runner.security_group.authorize(" |
1600 | - "'tcp', 443, 443, '10.0.0.5/32')\n\n" |
1601 | - "These demo networks have already been granted access on " |
1602 | - "port 80 and 443:\n" + demo_network_string + |
1603 | - "\n\nYou also need to edit your /etc/hosts to point\n" |
1604 | - "launchpad.dev at the ec2 instance's IP like this:\n" |
1605 | - " " + ec2_ip + " launchpad.dev\n\n" |
1606 | - "See " |
1607 | - "<https://wiki.canonical.com/Launchpad/EC2Test/ForDemos>." |
1608 | - "\n*****************************************************" |
1609 | - "\n\n") |
1610 | - # XXX: JonathanLange 2009-06-02: Blackbox alert! This uses the |
1611 | - # private '_instance' variable and assumes that the runner has |
1612 | - # exactly one instance. |
1613 | - if options.postmortem and runner._instance is not None: |
1614 | - console = code.InteractiveConsole({'runner': runner, 'e': e}) |
1615 | - console.interact(( |
1616 | - 'Postmortem Console. EC2 instance is not yet dead.\n' |
1617 | - 'It will shut down when you exit this prompt (CTRL-D).\n' |
1618 | - '\n' |
1619 | - 'Tab-completion is enabled.' |
1620 | - '\n' |
1621 | - 'Test runner instance is available as `runner`.\n' |
1622 | - 'Also try these:\n' |
1623 | - ' http://%(dns)s/current_test.log\n' |
1624 | - ' ssh -A %(dns)s') % |
1625 | - # XXX: JonathanLange 2009-06-02: Blackbox |
1626 | - # alert! See above. |
1627 | - {'dns': runner._instance.hostname}) |
1628 | - print 'Postmortem console closed.' |
1629 | - finally: |
1630 | - runner.shutdown() |
1631 | +from devscripts.ec2test.commandline import main |
1632 | +main # shut up pyflakes |
1633 | |
1634 | === added file 'lib/devscripts/ec2test/account.py' |
1635 | --- lib/devscripts/ec2test/account.py 1970-01-01 00:00:00 +0000 |
1636 | +++ lib/devscripts/ec2test/account.py 2009-09-10 23:59:12 +0000 |
1637 | @@ -0,0 +1,231 @@ |
1638 | +# Copyright 2009 Canonical Ltd. This software is licensed under the |
1639 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
1640 | + |
1641 | +"""A representation of an Amazon Web Services account.""" |
1642 | + |
1643 | +__metaclass__ = type |
1644 | +__all__ = [ |
1645 | + 'EC2Account', |
1646 | + ] |
1647 | + |
1648 | +import cStringIO |
1649 | +import re |
1650 | +import sys |
1651 | +import urllib |
1652 | + |
1653 | +from boto.exception import EC2ResponseError |
1654 | + |
1655 | +import paramiko |
1656 | + |
1657 | +VALID_AMI_OWNERS = ( |
1658 | + 255383312499, # gary |
1659 | + 559320013529, # flacoste |
1660 | + 200337130613, # mwhudson |
1661 | + # ...anyone else want in on the fun? |
1662 | + ) |
1663 | + |
1664 | + |
1665 | +def get_ip(): |
1666 | + """Uses AWS checkip to obtain this machine's IP address. |
1667 | + |
1668 | + Consults an external website to determine the public IP address of this |
1669 | + machine. |
1670 | + |
1671 | + :return: This machine's net-visible IP address as a string. |
1672 | + """ |
1673 | + return urllib.urlopen('http://checkip.amazonaws.com').read().strip() |
1674 | + |
1675 | + |
1676 | +class EC2Account: |
1677 | + """An EC2 account. |
1678 | + |
1679 | + You can use this to manage security groups, keys and images for an EC2 |
1680 | + account. |
1681 | + """ |
1682 | + |
1683 | + # Used to find pre-configured Amazon images. |
1684 | + _image_match = re.compile( |
1685 | + r'launchpad-ec2test(\d+)/image.manifest.xml$').match |
1686 | + |
1687 | + def __init__(self, name, connection): |
1688 | + """Construct an EC2 instance. |
1689 | + |
1690 | + :param name: ??? |
1691 | + :param connection: An open boto ec2 connection. |
1692 | + """ |
1693 | + self.name = name |
1694 | + self.conn = connection |
1695 | + |
1696 | + def log(self, msg): |
1697 | + """Log a message on stdout, flushing afterwards.""" |
1698 | + # XXX: JonathanLange 2009-05-31 bug=383076: Copied from EC2TestRunner. |
1699 | + # Should change EC2Account to take a logger and use that instead of |
1700 | + # writing to stdout. |
1701 | + sys.stdout.write(msg) |
1702 | + sys.stdout.flush() |
1703 | + |
1704 | + def acquire_security_group(self, demo_networks=None): |
1705 | + """Get a security group with the appropriate configuration. |
1706 | + |
1707 | + "Appropriate" means configured to allow this machine to connect via |
1708 | + SSH, HTTP and HTTPS. |
1709 | + |
1710 | + If a group is already configured with this name for this connection, |
1711 | + then re-use that. Otherwise, create a new security group and configure |
1712 | + it appropriately. |
1713 | + |
1714 | + The name of the security group is the `EC2Account.name` attribute. |
1715 | + |
1716 | + :return: A boto security group. |
1717 | + """ |
1718 | + if demo_networks is None: |
1719 | + demo_networks = [] |
1720 | + try: |
1721 | + group = self.conn.get_all_security_groups(self.name)[0] |
1722 | + except EC2ResponseError, e: |
1723 | + if e.code != 'InvalidGroup.NotFound': |
1724 | + raise |
1725 | + else: |
1726 | + # If an existing security group was configured, try deleting it |
1727 | + # since our external IP might have changed. |
1728 | + try: |
1729 | + group.delete() |
1730 | + except EC2ResponseError, e: |
1731 | + if e.code != 'InvalidGroup.InUse': |
1732 | + raise |
1733 | + # Otherwise, it means that an instance is already using |
1734 | + # it, so simply re-use it. It's unlikely that our IP changed! |
1735 | + # |
1736 | + # XXX: JonathanLange 2009-06-05: If the security group exists |
1737 | + # already, verify that the current IP is permitted; if it is |
1738 | + # not, make an INFO log and add the current IP. |
1739 | + self.log("Security group already in use, so reusing.") |
1740 | + return group |
1741 | + |
1742 | + security_group = self.conn.create_security_group( |
1743 | + self.name, 'Authorization to access the test runner instance.') |
1744 | + # Authorize SSH and HTTP. |
1745 | + ip = get_ip() |
1746 | + security_group.authorize('tcp', 22, 22, '%s/32' % ip) |
1747 | + security_group.authorize('tcp', 80, 80, '%s/32' % ip) |
1748 | + security_group.authorize('tcp', 443, 443, '%s/32' % ip) |
1749 | + for network in demo_networks: |
1750 | + # Add missing netmask info for single ips. |
1751 | + if '/' not in network: |
1752 | + network += '/32' |
1753 | + security_group.authorize('tcp', 80, 80, network) |
1754 | + security_group.authorize('tcp', 443, 443, network) |
1755 | + return security_group |
1756 | + |
1757 | + def acquire_private_key(self): |
1758 | + """Create & return a new key pair for the test runner.""" |
1759 | + key_pair = self.conn.create_key_pair(self.name) |
1760 | + return paramiko.RSAKey.from_private_key( |
1761 | + cStringIO.StringIO(key_pair.material.encode('ascii'))) |
1762 | + |
1763 | + def delete_previous_key_pair(self): |
1764 | + """Delete previously used keypair, if it exists.""" |
1765 | + try: |
1766 | + # Only one keypair will match 'self.name' since it's a unique |
1767 | + # identifier. |
1768 | + key_pairs = self.conn.get_all_key_pairs(self.name) |
1769 | + assert len(key_pairs) == 1, ( |
1770 | + "Should be only one keypair, found %d (%s)" |
1771 | + % (len(key_pairs), key_pairs)) |
1772 | + key_pair = key_pairs[0] |
1773 | + key_pair.delete() |
1774 | + except EC2ResponseError, e: |
1775 | + if e.code != 'InvalidKeyPair.NotFound': |
1776 | + if e.code == 'AuthFailure': |
1777 | + # Inserted because of previous support issue. |
1778 | + self.log( |
1779 | + 'POSSIBLE CAUSES OF ERROR:\n' |
1780 | + ' Did you sign up for EC2?\n' |
1781 | + ' Did you put a credit card number in your AWS ' |
1782 | + 'account?\n' |
1783 | + 'Please doublecheck before reporting a problem.\n') |
1784 | + raise |
1785 | + |
1786 | + def acquire_image(self, machine_id): |
1787 | + """Get the image. |
1788 | + |
1789 | + If 'machine_id' is None, then return the image with location that |
1790 | + matches `EC2Account._image_match` and has the highest revision number |
1791 | + (where revision number is the 'NN' in 'launchpad-ec2testNN'). |
1792 | + |
1793 | + Otherwise, just return the image with the given 'machine_id'. |
1794 | + |
1795 | + :raise ValueError: if there is more than one image with the same |
1796 | + location string. |
1797 | + |
1798 | + :raise RuntimeError: if we cannot find a test-runner image. |
1799 | + |
1800 | + :return: A boto image. |
1801 | + """ |
1802 | + if machine_id is not None: |
1803 | + # This may raise an exception. The user specified a machine_id, so |
1804 | + # they can deal with it. |
1805 | + return self.conn.get_image(machine_id) |
1806 | + |
1807 | + # We are trying to find an image that has a location that matches a |
1808 | + # regex (see definition of _image_match, above). Part of that regex is |
1809 | + # expected to be an integer with the semantics of a revision number. |
1810 | + # The image location with the highest revision number is the one that |
1811 | + # should be chosen. Because AWS does not guarantee that two images |
1812 | + # cannot share a location string, we need to make sure that the search |
1813 | + # result for this image is unique, or throw an error because the |
1814 | + # choice of image is ambiguous. |
1815 | + search_results = None |
1816 | + |
1817 | + # Find the images with the highest revision numbers and locations that |
1818 | + # match the regex. |
1819 | + for image in self.conn.get_all_images(owners=VALID_AMI_OWNERS): |
1820 | + match = self._image_match(image.location) |
1821 | + if match: |
1822 | + revision = int(match.group(1)) |
1823 | + if (search_results is None |
1824 | + or search_results['revision'] < revision): |
1825 | + # Then we have our first, highest match. |
1826 | + search_results = {'revision': revision, 'images': [image]} |
1827 | + elif search_results['revision'] == revision: |
1828 | + # Another image that matches and is equally high. |
1829 | + search_results['images'].append(image) |
1830 | + |
1831 | + # No matching image. |
1832 | + if search_results is None: |
1833 | + raise RuntimeError( |
1834 | + "You don't have access to a test-runner image.\n" |
1835 | + "Request access and try again.\n") |
1836 | + |
1837 | + # More than one matching image. |
1838 | + if len(search_results['images']) > 1: |
1839 | + raise ValueError( |
1840 | + ('more than one image of revision %(revision)d found: ' |
1841 | + '%(images)r') % search_results) |
1842 | + |
1843 | + # We could put a minimum image version number check here. |
1844 | + image = search_results['images'][0] |
1845 | + self.log( |
1846 | + 'Using machine image version %d\n' |
1847 | + % (search_results['revision'],)) |
1848 | + return image |
1849 | + |
1850 | + def get_instance(self, instance_id): |
1851 | + """Look in all of our reservations for an instance with the given ID. |
1852 | + |
1853 | + Return the instance object if it exists, None otherwise. |
1854 | + """ |
1855 | + # XXX mars 20090729 |
1856 | + # This method is needed by the ec2-generate-windmill-image.py script, |
1857 | + # so please do not delete it. |
1858 | + # |
1859 | + # This is a strange object on which to put this method, but I did |
1860 | + # not want to break encapsulation around the self.conn attribute. |
1861 | + |
1862 | + for reservation in self.conn.get_all_instances(): |
1863 | + # We need to look inside each reservation for the instances |
1864 | + # themselves. |
1865 | + for instance in reservation.instances: |
1866 | + if instance.id == instance_id: |
1867 | + return instance |
1868 | + return None |
1869 | |
1870 | === added file 'lib/devscripts/ec2test/commandline.py' |
1871 | --- lib/devscripts/ec2test/commandline.py 1970-01-01 00:00:00 +0000 |
1872 | +++ lib/devscripts/ec2test/commandline.py 2009-09-10 23:59:12 +0000 |
1873 | @@ -0,0 +1,292 @@ |
1874 | +# Copyright 2009 Canonical Ltd. This software is licensed under the |
1875 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
1876 | + |
1877 | +"""The command line parsing and entrypoint for ec2test.""" |
1878 | + |
1879 | +__metaclass__ = type |
1880 | +__all__ = [ |
1881 | + 'main', |
1882 | + ] |
1883 | + |
1884 | +import code |
1885 | +import optparse |
1886 | +import socket |
1887 | +import traceback |
1888 | +# The rlcompleter and readline modules change the behavior of the python |
1889 | +# interactive interpreter just by being imported. |
1890 | +import readline |
1891 | +import rlcompleter |
1892 | +# Shut up pyflakes. |
1893 | +rlcompleter |
1894 | + |
1895 | + |
1896 | +from devscripts.ec2test.testrunner import ( |
1897 | + AVAILABLE_INSTANCE_TYPES, DEFAULT_INSTANCE_TYPE, EC2TestRunner, |
1898 | + TRUNK_BRANCH) |
1899 | + |
1900 | +readline.parse_and_bind('tab: complete') |
1901 | + |
1902 | +# XXX: JonathanLange 2009-05-31: Strongly considering turning this into a |
1903 | +# Bazaar plugin -- probably would make the option parsing and validation |
1904 | +# easier. |
1905 | + |
1906 | +def main(): |
1907 | + parser = optparse.OptionParser( |
1908 | + usage="%prog [options] [branch]", |
1909 | + description=( |
1910 | + "Check out a Launchpad branch and run all tests on an Amazon " |
1911 | + "EC2 instance.")) |
1912 | + parser.add_option( |
1913 | + '-f', '--file', dest='file', default=None, |
1914 | + help=('Store abridged test results in FILE.')) |
1915 | + parser.add_option( |
1916 | + '-n', '--no-email', dest='no_email', default=False, |
1917 | + action='store_true', |
1918 | + help=('Do not try to email results.')) |
1919 | + parser.add_option( |
1920 | + '-e', '--email', action='append', dest='email', default=None, |
1921 | + help=('Email address to which results should be mailed. Defaults to ' |
1922 | + 'the email address from `bzr whoami`. May be supplied multiple ' |
1923 | + 'times. The first supplied email address will be used as the ' |
1924 | + 'From: address.')) |
1925 | + parser.add_option( |
1926 | + '-o', '--test-options', dest='test_options', default='-vv', |
1927 | + help=('Test options to pass to the remote test runner. Defaults to ' |
1928 | + "``-o '-vv'``. For instance, to run specific tests, you might " |
1929 | + "use ``-o '-vvt my_test_pattern'``.")) |
1930 | + parser.add_option( |
1931 | + '-b', '--branch', action='append', dest='branches', |
1932 | + help=('Branches to include in this run in sourcecode. ' |
1933 | + 'If the argument is only the project name, the trunk will be ' |
1934 | + 'used (e.g., ``-b launchpadlib``). If you want to use a ' |
1935 | + 'specific branch, if it is on launchpad, you can usually ' |
1936 | + 'simply specify it instead (e.g., ' |
1937 | + '``-b lp:~username/launchpadlib/branchname``). If this does ' |
1938 | + 'not appear to work, or if the desired branch is not on ' |
1939 | + 'launchpad, specify the project name and then the branch ' |
1940 | + 'after an equals sign (e.g., ' |
1941 | + '``-b launchpadlib=lp:~username/launchpadlib/branchname``). ' |
1942 | + 'Branches for multiple projects may be specified with ' |
1943 | + 'multiple instances of this option. ' |
1944 | + 'You may also use this option to specify the branch of launchpad ' |
1945 | + 'into which your branch may be merged. This defaults to %s. ' |
1946 | + 'Because typically the important branches of launchpad are owned ' |
1947 | + 'by the launchpad-pqm user, you can shorten this to only the ' |
1948 | + 'branch name, if desired, and the launchpad-pqm user will be ' |
1949 | + 'assumed. For instance, if you specify ' |
1950 | + '``-b launchpad=db-devel`` then this is equivalent to ' |
1951 | + '``-b lp:~launchpad-pqm/launchpad/db-devel``, or the even longer' |
1952 | + '``-b launchpad=lp:~launchpad-pqm/launchpad/db-devel``.' |
1953 | + % (TRUNK_BRANCH,))) |
1954 | + parser.add_option( |
1955 | + '-t', '--trunk', dest='trunk', default=False, |
1956 | + action='store_true', |
1957 | + help=('Run the trunk as the branch')) |
1958 | + parser.add_option( |
1959 | + '-s', '--submit-pqm-message', dest='pqm_message', default=None, |
1960 | + help=('A pqm message to submit if the test run is successful. If ' |
1961 | + 'provided, you will be asked for your GPG passphrase before ' |
1962 | + 'the test run begins.')) |
1963 | + parser.add_option( |
1964 | + '--pqm-public-location', dest='pqm_public_location', default=None, |
1965 | + help=('The public location for the pqm submit, if a pqm message is ' |
1966 | + 'provided (see --submit-pqm-message). If this is not provided, ' |
1967 | + 'for local branches, bzr configuration is consulted; for ' |
1968 | + 'remote branches, it is assumed that the remote branch *is* ' |
1969 | + 'a public branch.')) |
1970 | + parser.add_option( |
1971 | + '--pqm-submit-location', dest='pqm_submit_location', default=None, |
1972 | + help=('The submit location for the pqm submit, if a pqm message is ' |
1973 | + 'provided (see --submit-pqm-message). If this option is not ' |
1974 | + 'provided, the script will look for an explicitly specified ' |
1975 | + 'launchpad branch using the -b/--branch option; if that branch ' |
1976 | + 'was specified and is owned by the launchpad-pqm user on ' |
1977 | + 'launchpad, it is used as the pqm submit location. Otherwise, ' |
1978 | + 'for local branches, bzr configuration is consulted; for ' |
1979 | + 'remote branches, it is assumed that the submit branch is %s.' |
1980 | + % (TRUNK_BRANCH,))) |
1981 | + parser.add_option( |
1982 | + '--pqm-email', dest='pqm_email', default=None, |
1983 | + help=('Specify the email address of the PQM you are submitting to. ' |
1984 | + 'If the branch is local, then the bzr configuration is ' |
1985 | + 'consulted; for remote branches "Launchpad PQM ' |
1986 | + '<launchpad@pqm.canonical.com>" is used by default.')) |
1987 | + parser.add_option( |
1988 | + '-m', '--machine', dest='machine_id', default=None, |
1989 | + help=('The AWS machine identifier (AMID) on which to base this run. ' |
1990 | + 'You should typically only have to supply this if you are ' |
1991 | + 'testing new AWS images. Defaults to trying to find the most ' |
1992 | + 'recent one with an approved owner.')) |
1993 | + parser.add_option( |
1994 | + '-i', '--instance', dest='instance_type', |
1995 | + default=DEFAULT_INSTANCE_TYPE, |
1996 | + help=('The AWS instance type on which to base this run. ' |
1997 | + 'Available options are %r. Defaults to `%s`.' % |
1998 | + (AVAILABLE_INSTANCE_TYPES, DEFAULT_INSTANCE_TYPE))) |
1999 | + parser.add_option( |
2000 | + '-p', '--postmortem', dest='postmortem', default=False, |
2001 | + action='store_true', |
2002 | + help=('Drop to interactive prompt after the test and before shutting ' |
2003 | + 'down the instance for postmortem analysis of the EC2 instance ' |
2004 | + 'and/or of this script.')) |
2005 | + parser.add_option( |
2006 | + '--headless', dest='headless', default=False, |
2007 | + action='store_true', |
2008 | + help=('After building the instance and test, run the remote tests ' |
2009 | + 'headless. Cannot be used with postmortem ' |
2010 | + 'or file.')) |
2011 | + parser.add_option( |
2012 | + '-d', '--debug', dest='debug', default=False, |
2013 | + action='store_true', |
2014 | + help=('Drop to pdb trace as soon as possible.')) |
2015 | + # Use tabs to force a newline in the help text. |
2016 | + fake_newline = "\t\t\t\t\t\t\t" |
2017 | + parser.add_option( |
2018 | + '--demo', action='append', dest='demo_networks', |
2019 | + help=("Don't run tests. Instead start a demo instance of Launchpad. " |
2020 | + "You can allow multiple networks to access the demo by " |
2021 | + "repeating the argument." + fake_newline + |
2022 | + "Example: --demo 192.168.1.100 --demo 10.1.13.0/24" + |
2023 | + fake_newline + |
2024 | + "See" + fake_newline + |
2025 | + "https://wiki.canonical.com/Launchpad/EC2Test/ForDemos" )) |
2026 | + parser.add_option( |
2027 | + '--open-browser', dest='open_browser', default=False, |
2028 | + action='store_true', |
2029 | + help=('Open the results page in your default browser')) |
2030 | + parser.add_option( |
2031 | + '-c', '--include-download-cache-changes', |
2032 | + dest='include_download_cache_changes', action='store_true', |
2033 | + help=('Include any changes in the download cache (added or unknown) ' |
2034 | + 'in the download cache of the test run. Note that, if you have ' |
2035 | + 'any changes in your download cache, trying to submit to pqm ' |
2036 | + 'will always raise an error. Also note that, if you have any ' |
2037 | + 'changes in your download cache, you must explicitly choose to ' |
2038 | + 'include or ignore the changes.')) |
2039 | + parser.add_option( |
2040 | + '-g', '--ignore-download-cache-changes', |
2041 | + dest='include_download_cache_changes', action='store_false', |
2042 | + help=('Ignore any changes in the download cache (added or unknown) ' |
2043 | + 'in the download cache of the test run. Note that, if you have ' |
2044 | + 'any changes in your download cache, trying to submit to pqm ' |
2045 | + 'will always raise an error. Also note that, if you have any ' |
2046 | + 'changes in your download cache, you must explicitly choose to ' |
2047 | + 'include or ignore the changes.')) |
2048 | + options, args = parser.parse_args() |
2049 | + if options.debug: |
2050 | + import pdb; pdb.set_trace() |
2051 | + if options.demo_networks: |
2052 | + # We need the postmortem console to open the ec2 instance's |
2053 | + # network access, and to keep the ec2 instance from being shutdown. |
2054 | + options.postmortem = True |
2055 | + if len(args) == 1: |
2056 | + if options.trunk: |
2057 | + parser.error( |
2058 | + 'Cannot supply both a branch and the --trunk argument.') |
2059 | + branch = args[0] |
2060 | + elif len(args) > 1: |
2061 | + parser.error('Too many arguments.') |
2062 | + elif options.trunk: |
2063 | + branch = None |
2064 | + else: |
2065 | + branch = '.' |
2066 | + if ((options.postmortem or options.file or options.demo_networks) |
2067 | + and options.headless): |
2068 | + parser.error( |
2069 | + 'Headless mode currently does not support postmortem, file ' |
2070 | + 'or demo options.') |
2071 | + if options.no_email: |
2072 | + if options.email: |
2073 | + parser.error( |
2074 | + 'May not supply both --no-email and an --email address') |
2075 | + email = False |
2076 | + else: |
2077 | + email = options.email |
2078 | + if email is None: |
2079 | + email = True |
2080 | + if options.instance_type not in AVAILABLE_INSTANCE_TYPES: |
2081 | + parser.error('Unknown instance type.') |
2082 | + if options.branches is None: |
2083 | + branches = () |
2084 | + else: |
2085 | + branches = [data.split('=', 1) for data in options.branches] |
2086 | + runner = EC2TestRunner( |
2087 | + branch, email=email, file=options.file, |
2088 | + test_options=options.test_options, headless=options.headless, |
2089 | + branches=branches, |
2090 | + machine_id=options.machine_id, instance_type=options.instance_type, |
2091 | + pqm_message=options.pqm_message, |
2092 | + pqm_public_location=options.pqm_public_location, |
2093 | + pqm_submit_location=options.pqm_submit_location, |
2094 | + demo_networks=options.demo_networks, |
2095 | + open_browser=options.open_browser, pqm_email=options.pqm_email, |
2096 | + include_download_cache_changes=options.include_download_cache_changes, |
2097 | + ) |
2098 | + e = None |
2099 | + try: |
2100 | + try: |
2101 | + runner.start() |
2102 | + runner.configure_system() |
2103 | + runner.prepare_tests() |
2104 | + if options.demo_networks: |
2105 | + runner.start_demo_webserver() |
2106 | + else: |
2107 | + runner.run_tests() |
2108 | + except Exception, e: |
2109 | + # If we are running in demo or postmortem mode, it is really |
2110 | + # helpful to see if there are any exceptions before it waits |
2111 | + # in the console (in the finally block), and you can't figure |
2112 | + # out why it's broken. |
2113 | + traceback.print_exc() |
2114 | + finally: |
2115 | + try: |
2116 | + # XXX: JonathanLange 2009-06-02: Blackbox alert! This gets at the |
2117 | + # private _instance variable of runner. Instead, it should do |
2118 | + # something smarter. For example, the demo networks stuff could be |
2119 | + # extracted out to different, non-TestRunner class that has an |
2120 | + # instance. |
2121 | + if options.demo_networks and runner._instance is not None: |
2122 | + demo_network_string = '\n'.join( |
2123 | + ' ' + network for network in options.demo_networks) |
2124 | + # XXX: JonathanLange 2009-06-02: Blackbox alert! See above. |
2125 | + ec2_ip = socket.gethostbyname(runner._instance.hostname) |
2126 | + print ( |
2127 | + "\n\n" |
2128 | + "********************** DEMO *************************\n" |
2129 | + "It may take 20 seconds for the demo server to start up." |
2130 | + "\nTo demo to other users, you still need to open up\n" |
2131 | + "network access to the ec2 instance from their IPs by\n" |
2132 | + "entering command like this in the interactive python\n" |
2133 | + "interpreter at the end of the setup. " |
2134 | + "\n runner.security_group.authorize(" |
2135 | + "'tcp', 443, 443, '10.0.0.5/32')\n\n" |
2136 | + "These demo networks have already been granted access on " |
2137 | + "port 80 and 443:\n" + demo_network_string + |
2138 | + "\n\nYou also need to edit your /etc/hosts to point\n" |
2139 | + "launchpad.dev at the ec2 instance's IP like this:\n" |
2140 | + " " + ec2_ip + " launchpad.dev\n\n" |
2141 | + "See " |
2142 | + "<https://wiki.canonical.com/Launchpad/EC2Test/ForDemos>." |
2143 | + "\n*****************************************************" |
2144 | + "\n\n") |
2145 | + # XXX: JonathanLange 2009-06-02: Blackbox alert! This uses the |
2146 | + # private '_instance' variable and assumes that the runner has |
2147 | + # exactly one instance. |
2148 | + if options.postmortem and runner._instance is not None: |
2149 | + console = code.InteractiveConsole({'runner': runner, 'e': e}) |
2150 | + console.interact(( |
2151 | + 'Postmortem Console. EC2 instance is not yet dead.\n' |
2152 | + 'It will shut down when you exit this prompt (CTRL-D).\n' |
2153 | + '\n' |
2154 | + 'Tab-completion is enabled.' |
2155 | + '\n' |
2156 | + 'Test runner instance is available as `runner`.\n' |
2157 | + 'Also try these:\n' |
2158 | + ' http://%(dns)s/current_test.log\n' |
2159 | + ' ssh -A %(dns)s') % |
2160 | + # XXX: JonathanLange 2009-06-02: Blackbox |
2161 | + # alert! See above. |
2162 | + {'dns': runner._instance.hostname}) |
2163 | + print 'Postmortem console closed.' |
2164 | + finally: |
2165 | + runner.shutdown() |
2166 | |
2167 | === added file 'lib/devscripts/ec2test/credentials.py' |
2168 | --- lib/devscripts/ec2test/credentials.py 1970-01-01 00:00:00 +0000 |
2169 | +++ lib/devscripts/ec2test/credentials.py 2009-09-10 23:59:12 +0000 |
2170 | @@ -0,0 +1,62 @@ |
2171 | +# Copyright 2009 Canonical Ltd. This software is licensed under the |
2172 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
2173 | + |
2174 | +"""Support for reading Amazon Web Service credentials from '~/.ec2/aws_id'.""" |
2175 | + |
2176 | +__metaclass__ = type |
2177 | +__all__ = [ |
2178 | + 'CredentialsError', |
2179 | + 'EC2Credentials', |
2180 | + ] |
2181 | + |
2182 | +import os |
2183 | + |
2184 | +import boto |
2185 | + |
2186 | +from devscripts.ec2test.account import EC2Account |
2187 | + |
2188 | +class CredentialsError(Exception): |
2189 | + """Raised when AWS credentials could not be loaded.""" |
2190 | + |
2191 | + def __init__(self, filename, extra=None): |
2192 | + message = ( |
2193 | + "Please put your aws access key identifier and secret access " |
2194 | + "key identifier in %s. (On two lines)." % (filename,)) |
2195 | + if extra: |
2196 | + message += extra |
2197 | + Exception.__init__(self, message) |
2198 | + |
2199 | + |
2200 | +class EC2Credentials: |
2201 | + """Credentials for logging in to EC2.""" |
2202 | + |
2203 | + DEFAULT_CREDENTIALS_FILE = '~/.ec2/aws_id' |
2204 | + |
2205 | + def __init__(self, identifier, secret): |
2206 | + self.identifier = identifier |
2207 | + self.secret = secret |
2208 | + |
2209 | + @classmethod |
2210 | + def load_from_file(cls, filename=None): |
2211 | + """Load the EC2 credentials from 'filename'.""" |
2212 | + if filename is None: |
2213 | + filename = os.path.expanduser(cls.DEFAULT_CREDENTIALS_FILE) |
2214 | + try: |
2215 | + aws_file = open(filename, 'r') |
2216 | + except (IOError, OSError), e: |
2217 | + raise CredentialsError(filename, str(e)) |
2218 | + try: |
2219 | + identifier = aws_file.readline().strip() |
2220 | + secret = aws_file.readline().strip() |
2221 | + finally: |
2222 | + aws_file.close() |
2223 | + return cls(identifier, secret) |
2224 | + |
2225 | + def connect(self, name): |
2226 | + """Connect to EC2 with these credentials. |
2227 | + |
2228 | + :param name: ??? |
2229 | + :return: An `EC2Account` connected to EC2 with these credentials. |
2230 | + """ |
2231 | + conn = boto.connect_ec2(self.identifier, self.secret) |
2232 | + return EC2Account(name, conn) |
2233 | |
2234 | === added file 'lib/devscripts/ec2test/instance.py' |
2235 | --- lib/devscripts/ec2test/instance.py 1970-01-01 00:00:00 +0000 |
2236 | +++ lib/devscripts/ec2test/instance.py 2009-09-10 23:59:12 +0000 |
2237 | @@ -0,0 +1,214 @@ |
2238 | +# Copyright 2009 Canonical Ltd. This software is licensed under the |
2239 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
2240 | + |
2241 | +"""Code to represent a single machine instance in EC2.""" |
2242 | + |
2243 | +__metaclass__ = type |
2244 | +__all__ = [ |
2245 | + 'EC2Instance', |
2246 | + ] |
2247 | + |
2248 | +import select |
2249 | +import socket |
2250 | +import subprocess |
2251 | +import sys |
2252 | +import time |
2253 | + |
2254 | +import paramiko |
2255 | + |
2256 | + |
2257 | +class AcceptAllPolicy: |
2258 | + """We accept all unknown host key.""" |
2259 | + |
2260 | + # Normally the console output is supposed to contain the Host key |
2261 | + # but it doesn't seem to be the case here, so we trust that the host |
2262 | + # we are connecting to is the correct one. |
2263 | + def missing_host_key(self, client, hostname, key): |
2264 | + pass |
2265 | + |
2266 | + |
2267 | +class EC2Instance: |
2268 | + """A single EC2 instance.""" |
2269 | + |
2270 | + # XXX: JonathanLange 2009-05-31: Make it so that we pass one of these to |
2271 | + # EC2 test runner, rather than the test runner knowing how to make one. |
2272 | + # Right now, the test runner makes one of these directly. Instead, we want |
2273 | + # to make an EC2Account and ask it for one of these instances and then |
2274 | + # pass it to the test runner on construction. |
2275 | + |
2276 | + # XXX: JonathanLange 2009-05-31: Separate out demo server maybe? |
2277 | + |
2278 | + # XXX: JonathanLange 2009-05-31: Possibly separate out "get an instance" |
2279 | + # and "set up instance for Launchpad testing" logic. |
2280 | + |
2281 | + def __init__(self, name, image, instance_type, demo_networks, controller, |
2282 | + vals): |
2283 | + self._name = name |
2284 | + self._image = image |
2285 | + self._controller = controller |
2286 | + self._instance_type = instance_type |
2287 | + self._demo_networks = demo_networks |
2288 | + self._boto_instance = None |
2289 | + self._vals = vals |
2290 | + |
2291 | + def error_and_quit(self, msg): |
2292 | + """Print error message and exit.""" |
2293 | + sys.stderr.write(msg) |
2294 | + sys.exit(1) |
2295 | + |
2296 | + def log(self, msg): |
2297 | + """Log a message on stdout, flushing afterwards.""" |
2298 | + # XXX: JonathanLange 2009-05-31 bug=383076: Should delete this and use |
2299 | + # Python logging module instead. |
2300 | + sys.stdout.write(msg) |
2301 | + sys.stdout.flush() |
2302 | + |
2303 | + def start(self): |
2304 | + """Start the instance.""" |
2305 | + if self._boto_instance is not None: |
2306 | + self.log('Instance %s already started' % self._boto_instance.id) |
2307 | + return |
2308 | + start = time.time() |
2309 | + self.private_key = self._controller.acquire_private_key() |
2310 | + self._controller.acquire_security_group( |
2311 | + demo_networks=self._demo_networks) |
2312 | + reservation = self._image.run( |
2313 | + key_name=self._name, security_groups=[self._name], |
2314 | + instance_type=self._instance_type) |
2315 | + self._boto_instance = reservation.instances[0] |
2316 | + self.log('Instance %s starting..' % self._boto_instance.id) |
2317 | + while self._boto_instance.state == 'pending': |
2318 | + self.log('.') |
2319 | + time.sleep(5) |
2320 | + self._boto_instance.update() |
2321 | + if self._boto_instance.state == 'running': |
2322 | + self.log(' started on %s\n' % self.hostname) |
2323 | + elapsed = time.time() - start |
2324 | + self.log('Started in %d minutes %d seconds\n' % |
2325 | + (elapsed // 60, elapsed % 60)) |
2326 | + self._output = self._boto_instance.get_console_output() |
2327 | + self.log(self._output.output) |
2328 | + else: |
2329 | + self.error_and_quit( |
2330 | + 'failed to start: %s\n' % self._boto_instance.state) |
2331 | + |
2332 | + def shutdown(self): |
2333 | + """Shut down the instance.""" |
2334 | + if self._boto_instance is None: |
2335 | + self.log('no instance created\n') |
2336 | + return |
2337 | + self._boto_instance.update() |
2338 | + if self._boto_instance.state not in ('shutting-down', 'terminated'): |
2339 | + # terminate instance |
2340 | + self._boto_instance.stop() |
2341 | + self._boto_instance.update() |
2342 | + self.log('instance %s\n' % (self._boto_instance.state,)) |
2343 | + |
2344 | + @property |
2345 | + def hostname(self): |
2346 | + if self._boto_instance is None: |
2347 | + return None |
2348 | + return self._boto_instance.public_dns_name |
2349 | + |
2350 | + def connect_as_root(self): |
2351 | + """Connect to the instance as root. |
2352 | + |
2353 | + All subsequent 'perform' and 'subprocess' operations will be done with |
2354 | + root privileges. |
2355 | + """ |
2356 | + # XXX: JonathanLange 2009-06-02: This state-changing method could |
2357 | + # perhaps be written as a function such as run_as_root, or as a method |
2358 | + # that returns a root connection. |
2359 | + for count in range(10): |
2360 | + self.ssh = paramiko.SSHClient() |
2361 | + self.ssh.set_missing_host_key_policy(AcceptAllPolicy()) |
2362 | + self.username = 'root' |
2363 | + try: |
2364 | + self.ssh.connect( |
2365 | + self.hostname, username='root', |
2366 | + pkey=self.private_key, |
2367 | + allow_agent=False, look_for_keys=False) |
2368 | + except (socket.error, paramiko.AuthenticationException), e: |
2369 | + self.log('connect_as_root: %r' % (e,)) |
2370 | + if count < 9: |
2371 | + time.sleep(5) |
2372 | + self.log('retrying...') |
2373 | + else: |
2374 | + raise |
2375 | + else: |
2376 | + break |
2377 | + |
2378 | + def connect_as_user(self): |
2379 | + """Connect as user. |
2380 | + |
2381 | + All subsequent 'perform' and 'subprocess' operations will be done with |
2382 | + user-level privileges. |
2383 | + """ |
2384 | + # XXX: JonathanLange 2009-06-02: This state-changing method could |
2385 | + # perhaps be written as a function such as run_as_user, or as a method |
2386 | + # that returns a user connection. |
2387 | + # |
2388 | + # This does not have the retry logic of connect_as_root because the |
2389 | + # circumstances that make the retries necessary appear to only happen |
2390 | + # on start-up, and connect_as_root is called first. |
2391 | + self.ssh = paramiko.SSHClient() |
2392 | + self.ssh.set_missing_host_key_policy(AcceptAllPolicy()) |
2393 | + self.username = self._vals['USER'] |
2394 | + self.ssh.connect(self.hostname) |
2395 | + |
2396 | + def perform(self, cmd, ignore_failure=False, out=None): |
2397 | + """Perform 'cmd' on server. |
2398 | + |
2399 | + :param ignore_failure: If False, raise an error on non-zero exit |
2400 | + statuses. |
2401 | + :param out: A stream to write the output of the remote command to. |
2402 | + """ |
2403 | + cmd = cmd % self._vals |
2404 | + self.log('%s@%s$ %s\n' % (self.username, self._boto_instance.id, cmd)) |
2405 | + session = self.ssh.get_transport().open_session() |
2406 | + session.exec_command(cmd) |
2407 | + session.shutdown_write() |
2408 | + while 1: |
2409 | + select.select([session], [], [], 0.5) |
2410 | + if session.recv_ready(): |
2411 | + data = session.recv(4096) |
2412 | + if data: |
2413 | + sys.stdout.write(data) |
2414 | + sys.stdout.flush() |
2415 | + if out is not None: |
2416 | + out.write(data) |
2417 | + if session.recv_stderr_ready(): |
2418 | + data = session.recv_stderr(4096) |
2419 | + if data: |
2420 | + sys.stderr.write(data) |
2421 | + sys.stderr.flush() |
2422 | + if session.exit_status_ready(): |
2423 | + break |
2424 | + session.close() |
2425 | + # XXX: JonathanLange 2009-05-31: If the command is killed by a signal |
2426 | + # on the remote server, the SSH protocol does not send an exit_status, |
2427 | + # it instead sends a different message with the number of the signal |
2428 | + # that killed the process. AIUI, this code will fail confusingly if |
2429 | + # that happens. |
2430 | + res = session.recv_exit_status() |
2431 | + if res and not ignore_failure: |
2432 | + raise RuntimeError('Command failed: %s' % (cmd,)) |
2433 | + return res |
2434 | + |
2435 | + def run_with_ssh_agent(self, cmd, ignore_failure=False): |
2436 | + """Run 'cmd' in a subprocess. |
2437 | + |
2438 | + Use this to run commands that require local SSH credentials. For |
2439 | + example, getting private branches from Launchpad. |
2440 | + """ |
2441 | + cmd = cmd % self._vals |
2442 | + self.log('%s@%s$ %s\n' % (self.username, self._boto_instance.id, cmd)) |
2443 | + call = ['ssh', '-A', self.hostname, |
2444 | + '-o', 'CheckHostIP no', |
2445 | + '-o', 'StrictHostKeyChecking no', |
2446 | + '-o', 'UserKnownHostsFile ~/.ec2/known_hosts', |
2447 | + cmd] |
2448 | + res = subprocess.call(call) |
2449 | + if res and not ignore_failure: |
2450 | + raise RuntimeError('Command failed: %s' % (cmd,)) |
2451 | + return res |
2452 | |
2453 | === added file 'lib/devscripts/ec2test/testrunner.py' |
2454 | --- lib/devscripts/ec2test/testrunner.py 1970-01-01 00:00:00 +0000 |
2455 | +++ lib/devscripts/ec2test/testrunner.py 2009-09-10 23:59:12 +0000 |
2456 | @@ -0,0 +1,855 @@ |
2457 | +# Copyright 2009 Canonical Ltd. This software is licensed under the |
2458 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
2459 | + |
2460 | +"""Code to actually run the tests in an EC2 instance.""" |
2461 | + |
2462 | +__metaclass__ = type |
2463 | +__all__ = [ |
2464 | + 'AVAILABLE_INSTANCE_TYPES', |
2465 | + 'DEFAULT_INSTANCE_TYPE', |
2466 | + 'EC2TestRunner', |
2467 | + 'TRUNK_BRANCH', |
2468 | + ] |
2469 | + |
2470 | +import os |
2471 | +import pickle |
2472 | +import re |
2473 | +import sys |
2474 | + |
2475 | + |
2476 | +from bzrlib.plugin import load_plugins |
2477 | +load_plugins() |
2478 | +from bzrlib.branch import Branch |
2479 | +from bzrlib.bzrdir import BzrDir |
2480 | +from bzrlib.config import GlobalConfig |
2481 | +from bzrlib.errors import UncommittedChanges |
2482 | +from bzrlib.plugins.launchpad.account import get_lp_login |
2483 | +from bzrlib.plugins.pqm.pqm_submit import ( |
2484 | + NoPQMSubmissionAddress, PQMSubmission) |
2485 | + |
2486 | +import paramiko |
2487 | + |
2488 | +from devscripts.ec2test.instance import EC2Instance |
2489 | +from devscripts.ec2test.credentials import CredentialsError, EC2Credentials |
2490 | + |
2491 | +# XXX duplicated from __init__.py .. fix that |
2492 | +TRUNK_BRANCH = 'bzr+ssh://bazaar.launchpad.net/~launchpad-pqm/launchpad/devel' |
2493 | +DEFAULT_INSTANCE_TYPE = 'c1.xlarge' |
2494 | +AVAILABLE_INSTANCE_TYPES = ('m1.large', 'm1.xlarge', 'c1.xlarge') |
2495 | + |
2496 | +############################################################################# |
2497 | +# Modified from paramiko.config. The change should be pushed upstream. |
2498 | +# Our fork supports Host lines with more than one host. |
2499 | + |
2500 | +import fnmatch |
2501 | + |
2502 | +class SSHConfig (object): |
2503 | + """ |
2504 | + Representation of config information as stored in the format used by |
2505 | + OpenSSH. Queries can be made via L{lookup}. The format is described in |
2506 | + OpenSSH's C{ssh_config} man page. This class is provided primarily as a |
2507 | + convenience to posix users (since the OpenSSH format is a de-facto |
2508 | + standard on posix) but should work fine on Windows too. |
2509 | + |
2510 | + @since: 1.6 |
2511 | + """ |
2512 | + |
2513 | + def __init__(self): |
2514 | + """ |
2515 | + Create a new OpenSSH config object. |
2516 | + """ |
2517 | + self._config = [ { 'host': '*' } ] |
2518 | + |
2519 | + def parse(self, file_obj): |
2520 | + """ |
2521 | + Read an OpenSSH config from the given file object. |
2522 | + |
2523 | + @param file_obj: a file-like object to read the config file from |
2524 | + @type file_obj: file |
2525 | + """ |
2526 | + configs = [self._config[0]] |
2527 | + for line in file_obj: |
2528 | + line = line.rstrip('\n').lstrip() |
2529 | + if (line == '') or (line[0] == '#'): |
2530 | + continue |
2531 | + if '=' in line: |
2532 | + key, value = line.split('=', 1) |
2533 | + key = key.strip().lower() |
2534 | + else: |
2535 | + # find first whitespace, and split there |
2536 | + i = 0 |
2537 | + while (i < len(line)) and not line[i].isspace(): |
2538 | + i += 1 |
2539 | + if i == len(line): |
2540 | + raise Exception('Unparsable line: %r' % line) |
2541 | + key = line[:i].lower() |
2542 | + value = line[i:].lstrip() |
2543 | + |
2544 | + if key == 'host': |
2545 | + del configs[:] |
2546 | + # the value may be multiple hosts, space-delimited |
2547 | + for host in value.split(): |
2548 | + # do we have a pre-existing host config to append to? |
2549 | + matches = [c for c in self._config if c['host'] == host] |
2550 | + if len(matches) > 0: |
2551 | + configs.append(matches[0]) |
2552 | + else: |
2553 | + config = { 'host': host } |
2554 | + self._config.append(config) |
2555 | + configs.append(config) |
2556 | + else: |
2557 | + for config in configs: |
2558 | + config[key] = value |
2559 | + |
2560 | + def lookup(self, hostname): |
2561 | + """ |
2562 | + Return a dict of config options for a given hostname. |
2563 | + |
2564 | + The host-matching rules of OpenSSH's C{ssh_config} man page are used, |
2565 | + which means that all configuration options from matching host |
2566 | + specifications are merged, with more specific hostmasks taking |
2567 | + precedence. In other words, if C{"Port"} is set under C{"Host *"} |
2568 | + and also C{"Host *.example.com"}, and the lookup is for |
2569 | + C{"ssh.example.com"}, then the port entry for C{"Host *.example.com"} |
2570 | + will win out. |
2571 | + |
2572 | + The keys in the returned dict are all normalized to lowercase (look for |
2573 | + C{"port"}, not C{"Port"}. No other processing is done to the keys or |
2574 | + values. |
2575 | + |
2576 | + @param hostname: the hostname to lookup |
2577 | + @type hostname: str |
2578 | + """ |
2579 | + matches = [ |
2580 | + x for x in self._config if fnmatch.fnmatch(hostname, x['host'])] |
2581 | + # sort in order of shortest match (usually '*') to longest |
2582 | + matches.sort(lambda x,y: cmp(len(x['host']), len(y['host']))) |
2583 | + ret = {} |
2584 | + for m in matches: |
2585 | + ret.update(m) |
2586 | + del ret['host'] |
2587 | + return ret |
2588 | + |
2589 | +# END paramiko config fork |
2590 | +############################################################################# |
2591 | + |
2592 | +class UnknownBranchURL(Exception): |
2593 | + """Raised when we try to parse an unrecognized branch url.""" |
2594 | + |
2595 | + def __init__(self, branch_url): |
2596 | + Exception.__init__( |
2597 | + self, |
2598 | + "Couldn't parse '%s', not a Launchpad branch." % (branch_url,)) |
2599 | + |
2600 | +def validate_file(filename): |
2601 | + """Raise an error if 'filename' is not a file we can write to.""" |
2602 | + if filename is None: |
2603 | + return |
2604 | + |
2605 | + check_file = filename |
2606 | + if os.path.exists(check_file): |
2607 | + if not os.path.isfile(check_file): |
2608 | + raise ValueError( |
2609 | + 'file argument %s exists and is not a file' % (filename,)) |
2610 | + else: |
2611 | + check_file = os.path.dirname(check_file) |
2612 | + if (not os.path.exists(check_file) or |
2613 | + not os.path.isdir(check_file)): |
2614 | + raise ValueError( |
2615 | + 'file %s cannot be created.' % (filename,)) |
2616 | + if not os.access(check_file, os.W_OK): |
2617 | + raise ValueError( |
2618 | + 'you do not have permission to write %s' % (filename,)) |
2619 | + |
2620 | + |
2621 | +def parse_branch_url(branch_url): |
2622 | + """Given the URL of a branch, return its components in a dict.""" |
2623 | + _lp_match = re.compile( |
2624 | + r'lp:\~([^/]+)/([^/]+)/([^/]+)$').match |
2625 | + _bazaar_match = re.compile( |
2626 | + r'bzr+ssh://bazaar.launchpad.net/\~([^/]+)/([^/]+)/([^/]+)$').match |
2627 | + match = _lp_match(branch_url) |
2628 | + if match is None: |
2629 | + match = _bazaar_match(branch_url) |
2630 | + if match is None: |
2631 | + raise UnknownBranchURL(branch_url) |
2632 | + owner = match.group(1) |
2633 | + product = match.group(2) |
2634 | + branch = match.group(3) |
2635 | + unique_name = '~%s/%s/%s' % (owner, product, branch) |
2636 | + url = 'bzr+ssh://bazaar.launchpad.net/%s' % (unique_name,) |
2637 | + return dict( |
2638 | + owner=owner, product=product, branch=branch, unique_name=unique_name, |
2639 | + url=url) |
2640 | + |
2641 | + |
2642 | +def normalize_branch_input(data): |
2643 | + """Given 'data' return a ('dest', 'src') pair. |
2644 | + |
2645 | + :param data: One of:: |
2646 | + - a double of (sourcecode_location, branch_url). |
2647 | + If 'sourcecode_location' is Launchpad, then 'branch_url' can |
2648 | + also be the name of a branch of launchpad owned by |
2649 | + launchpad-pqm. |
2650 | + - a singleton of (branch_url,) |
2651 | + - a singleton of (sourcecode_location,) where |
2652 | + sourcecode_location corresponds to a Launchpad upstream |
2653 | + project as well as a rocketfuel sourcecode location. |
2654 | + - a string which could populate any of the above singletons. |
2655 | + |
2656 | + :return: ('dest', 'src') where 'dest' is the destination |
2657 | + sourcecode location in the rocketfuel tree and 'src' is the |
2658 | + URL of the branch to put there. The URL can be either a bzr+ssh |
2659 | + URL or the name of a branch of launchpad owned by launchpad-pqm. |
2660 | + """ |
2661 | + # XXX: JonathanLange 2009-06-05: Should convert lp: URL branches to |
2662 | + # bzr+ssh:// branches. |
2663 | + if isinstance(data, basestring): |
2664 | + data = (data,) |
2665 | + if len(data) == 2: |
2666 | + # Already in dest, src format. |
2667 | + return data |
2668 | + if len(data) != 1: |
2669 | + raise ValueError( |
2670 | + 'invalid argument for ``branches`` argument: %r' % |
2671 | + (data,)) |
2672 | + branch_location = data[0] |
2673 | + try: |
2674 | + parsed_url = parse_branch_url(branch_location) |
2675 | + except UnknownBranchURL: |
2676 | + return branch_location, 'lp:%s' % (branch_location,) |
2677 | + return parsed_url['product'], parsed_url['url'] |
2678 | + |
2679 | + |
2680 | +def parse_specified_branches(branches): |
2681 | + """Given 'branches' from the command line, return a sanitized dict. |
2682 | + |
2683 | + The dict maps sourcecode locations to branch URLs, according to the |
2684 | + rules in `normalize_branch_input`. |
2685 | + """ |
2686 | + return dict(map(normalize_branch_input, branches)) |
2687 | + |
2688 | + |
2689 | +class EC2TestRunner: |
2690 | + |
2691 | + name = 'ec2-test-runner' |
2692 | + |
2693 | + message = instance = image = None |
2694 | + _running = False |
2695 | + |
2696 | + def __init__(self, branch, email=False, file=None, test_options='-vv', |
2697 | + headless=False, branches=(), |
2698 | + machine_id=None, instance_type=DEFAULT_INSTANCE_TYPE, |
2699 | + pqm_message=None, pqm_public_location=None, |
2700 | + pqm_submit_location=None, demo_networks=None, |
2701 | + open_browser=False, pqm_email=None, |
2702 | + include_download_cache_changes=None): |
2703 | + """Create a new EC2TestRunner. |
2704 | + |
2705 | + This sets the following attributes: |
2706 | + - original_branch |
2707 | + - test_options |
2708 | + - headless |
2709 | + - include_download_cache_changes |
2710 | + - download_cache_additions |
2711 | + - branches (parses, validates) |
2712 | + - message (after validating PQM submisson) |
2713 | + - email (after validating email capabilities) |
2714 | + - instance_type (validates) |
2715 | + - image (after connecting to ec2) |
2716 | + - file (after checking we can write to it) |
2717 | + - ssh_config_file_name (after checking it exists) |
2718 | + - vals, a dict containing |
2719 | + - the environment |
2720 | + - trunk_branch (either from global or derived from branches) |
2721 | + - branch |
2722 | + - smtp_server |
2723 | + - smtp_username |
2724 | + - smtp_password |
2725 | + - email (distinct from the email attribute) |
2726 | + - key_type |
2727 | + - key |
2728 | + - launchpad_login |
2729 | + """ |
2730 | + self.original_branch = branch # just for easy access in debugging |
2731 | + self.test_options = test_options |
2732 | + self.headless = headless |
2733 | + self.include_download_cache_changes = include_download_cache_changes |
2734 | + if demo_networks is None: |
2735 | + demo_networks = () |
2736 | + else: |
2737 | + demo_networks = demo_networks |
2738 | + self.open_browser = open_browser |
2739 | + if headless and file: |
2740 | + raise ValueError( |
2741 | + 'currently do not support files with headless mode.') |
2742 | + if headless and not (email or pqm_message): |
2743 | + raise ValueError('You have specified no way to get the results ' |
2744 | + 'of your headless test run.') |
2745 | + |
2746 | + if test_options != '-vv' and pqm_message is not None: |
2747 | + raise ValueError( |
2748 | + "Submitting to PQM with non-default test options isn't " |
2749 | + "supported") |
2750 | + |
2751 | + trunk_specified = False |
2752 | + trunk_branch = TRUNK_BRANCH |
2753 | + |
2754 | + # normalize and validate branches |
2755 | + branches = parse_specified_branches(branches) |
2756 | + try: |
2757 | + launchpad_url = branches.pop('launchpad') |
2758 | + except KeyError: |
2759 | + # No Launchpad branch specified. |
2760 | + pass |
2761 | + else: |
2762 | + try: |
2763 | + parsed_url = parse_branch_url(launchpad_url) |
2764 | + except UnknownBranchURL: |
2765 | + user = 'launchpad-pqm' |
2766 | + src = ('bzr+ssh://bazaar.launchpad.net/' |
2767 | + '~launchpad-pqm/launchpad/%s' % (launchpad_url,)) |
2768 | + else: |
2769 | + user = parsed_url['owner'] |
2770 | + src = parsed_url['url'] |
2771 | + if user == 'launchpad-pqm': |
2772 | + trunk_specified = True |
2773 | + trunk_branch = src |
2774 | + |
2775 | + self.branches = branches.items() |
2776 | + |
2777 | + # XXX: JonathanLange 2009-05-31: The trunk_specified stuff above and |
2778 | + # the pqm location stuff below are actually doing the equivalent of |
2779 | + # preparing a merge directive. Perhaps we can leverage that to make |
2780 | + # this code simpler. |
2781 | + self.download_cache_additions = None |
2782 | + if branch is None: |
2783 | + config = GlobalConfig() |
2784 | + if pqm_message is not None: |
2785 | + raise ValueError('Cannot submit trunk to pqm.') |
2786 | + else: |
2787 | + (tree, |
2788 | + bzrbranch, |
2789 | + relpath) = BzrDir.open_containing_tree_or_branch(branch) |
2790 | + # if tree is None, remote...I'm assuming. |
2791 | + if tree is None: |
2792 | + config = GlobalConfig() |
2793 | + else: |
2794 | + config = bzrbranch.get_config() |
2795 | + |
2796 | + if pqm_message is not None or tree is not None: |
2797 | + # if we are going to maybe send a pqm_message, we're going to |
2798 | + # go down this path. Also, even if we are not but this is a |
2799 | + # local branch, we're going to use the PQM machinery to make |
2800 | + # sure that the local branch has been made public, and has all |
2801 | + # working changes there. |
2802 | + if tree is None: |
2803 | + # remote. We will make some assumptions. |
2804 | + if pqm_public_location is None: |
2805 | + pqm_public_location = branch |
2806 | + if pqm_submit_location is None: |
2807 | + pqm_submit_location = trunk_branch |
2808 | + elif pqm_submit_location is None and trunk_specified: |
2809 | + pqm_submit_location = trunk_branch |
2810 | + # modified from pqm_submit.py |
2811 | + submission = PQMSubmission( |
2812 | + source_branch=bzrbranch, |
2813 | + public_location=pqm_public_location, |
2814 | + message=pqm_message or '', |
2815 | + submit_location=pqm_submit_location, |
2816 | + tree=tree) |
2817 | + if tree is not None: |
2818 | + # this is the part we want to do whether or not we're |
2819 | + # submitting. |
2820 | + submission.check_tree() # any working changes |
2821 | + submission.check_public_branch() # everything public |
2822 | + branch = submission.public_location |
2823 | + if (include_download_cache_changes is None or |
2824 | + include_download_cache_changes): |
2825 | + # We need to get the download cache settings |
2826 | + cache_tree, cache_bzrbranch, cache_relpath = ( |
2827 | + BzrDir.open_containing_tree_or_branch( |
2828 | + os.path.join( |
2829 | + self.original_branch, 'download-cache'))) |
2830 | + cache_tree.lock_read() |
2831 | + try: |
2832 | + cache_basis_tree = cache_tree.basis_tree() |
2833 | + cache_basis_tree.lock_read() |
2834 | + try: |
2835 | + delta = cache_tree.changes_from( |
2836 | + cache_basis_tree, want_unversioned=True) |
2837 | + unversioned = [ |
2838 | + un for un in delta.unversioned |
2839 | + if not cache_tree.is_ignored(un[0])] |
2840 | + added = delta.added |
2841 | + self.download_cache_additions = ( |
2842 | + unversioned + added) |
2843 | + finally: |
2844 | + cache_basis_tree.unlock() |
2845 | + finally: |
2846 | + cache_tree.unlock() |
2847 | + if pqm_message is not None: |
2848 | + if self.download_cache_additions: |
2849 | + raise UncommittedChanges(cache_tree) |
2850 | + # get the submission message |
2851 | + mail_from = config.get_user_option('pqm_user_email') |
2852 | + if not mail_from: |
2853 | + mail_from = config.username() |
2854 | + # Make sure this isn't unicode |
2855 | + mail_from = mail_from.encode('utf8') |
2856 | + if pqm_email is None: |
2857 | + if tree is None: |
2858 | + pqm_email = ( |
2859 | + "Launchpad PQM <launchpad@pqm.canonical.com>") |
2860 | + else: |
2861 | + pqm_email = config.get_user_option('pqm_email') |
2862 | + if not pqm_email: |
2863 | + raise NoPQMSubmissionAddress(bzrbranch) |
2864 | + mail_to = pqm_email.encode('utf8') # same here |
2865 | + self.message = submission.to_email(mail_from, mail_to) |
2866 | + elif (self.download_cache_additions and |
2867 | + self.include_download_cache_changes is None): |
2868 | + raise UncommittedChanges( |
2869 | + cache_tree, |
2870 | + 'You must select whether to include download cache ' |
2871 | + 'changes (see --include-download-cache-changes and ' |
2872 | + '--ignore-download-cache-changes, -c and -g ' |
2873 | + 'respectively), or ' |
2874 | + 'commit or remove the files in the download-cache.') |
2875 | + if email is not False: |
2876 | + if email is True: |
2877 | + email = [config.username()] |
2878 | + if not email[0]: |
2879 | + raise ValueError('cannot find your email address.') |
2880 | + elif isinstance(email, basestring): |
2881 | + email = [email] |
2882 | + else: |
2883 | + tmp = [] |
2884 | + for item in email: |
2885 | + if not isinstance(item, basestring): |
2886 | + raise ValueError( |
2887 | + 'email must be True, False, a string, or a list of ' |
2888 | + 'strings') |
2889 | + tmp.append(item) |
2890 | + email = tmp |
2891 | + else: |
2892 | + email = None |
2893 | + self.email = email |
2894 | + |
2895 | + # We do a lot of looking before leaping here because we want to avoid |
2896 | + # wasting time and money on errors we could have caught early. |
2897 | + |
2898 | + # Validate instance_type and get default kernal and ramdisk. |
2899 | + if instance_type not in AVAILABLE_INSTANCE_TYPES: |
2900 | + raise ValueError('unknown instance_type %s' % (instance_type,)) |
2901 | + |
2902 | + # Validate and set file. |
2903 | + validate_file(file) |
2904 | + self.file = file |
2905 | + |
2906 | + # Make a dict for string substitution based on the environ. |
2907 | + # |
2908 | + # XXX: JonathanLange 2009-06-02: Although this defintely makes the |
2909 | + # scripts & commands easier to write, it makes it harder to figure out |
2910 | + # how the different bits of the system interoperate (passing 'vals' to |
2911 | + # a method means it uses...?). Consider changing things around so that |
2912 | + # vals is not needed. |
2913 | + self.vals = dict(os.environ) |
2914 | + self.vals['trunk_branch'] = trunk_branch |
2915 | + self.vals['branch'] = branch |
2916 | + home = self.vals['HOME'] |
2917 | + |
2918 | + # Email configuration. |
2919 | + if email is not None or pqm_message is not None: |
2920 | + server = self.vals['smtp_server'] = config.get_user_option( |
2921 | + 'smtp_server') |
2922 | + if server is None or server == 'localhost': |
2923 | + raise ValueError( |
2924 | + 'To send email, a remotely accessible smtp_server (and ' |
2925 | + 'smtp_username and smtp_password, if necessary) must be ' |
2926 | + 'configured in bzr. See the SMTP server information ' |
2927 | + 'here: https://wiki.canonical.com/EmailSetup .') |
2928 | + self.vals['smtp_username'] = config.get_user_option( |
2929 | + 'smtp_username') |
2930 | + self.vals['smtp_password'] = config.get_user_option( |
2931 | + 'smtp_password') |
2932 | + from_email = config.username() |
2933 | + if not from_email: |
2934 | + raise ValueError( |
2935 | + 'To send email, your bzr email address must be set ' |
2936 | + '(use ``bzr whoami``).') |
2937 | + else: |
2938 | + self.vals['email'] = ( |
2939 | + from_email.encode('utf8').encode('string-escape')) |
2940 | + |
2941 | + # Get a public key from the agent. |
2942 | + agent = paramiko.Agent() |
2943 | + keys = agent.get_keys() |
2944 | + if len(keys) == 0: |
2945 | + self.error_and_quit( |
2946 | + 'You must have an ssh agent running with keys installed that ' |
2947 | + 'will allow the script to rsync to devpad and get your ' |
2948 | + 'branch.\n') |
2949 | + key = agent.get_keys()[0] |
2950 | + self.vals['key_type'] = key.get_name() |
2951 | + self.vals['key'] = key.get_base64() |
2952 | + |
2953 | + # Verify the .ssh config file |
2954 | + self.ssh_config_file_name = os.path.join(home, '.ssh', 'config') |
2955 | + if not os.path.exists(self.ssh_config_file_name): |
2956 | + self.error_and_quit( |
2957 | + 'This script expects to find the .ssh config in %s. Please ' |
2958 | + 'make sure it exists and contains the necessary ' |
2959 | + 'configuration to access devpad.' % ( |
2960 | + self.ssh_config_file_name,)) |
2961 | + |
2962 | + # Get the bzr login. |
2963 | + login = get_lp_login() |
2964 | + if not login: |
2965 | + self.error_and_quit( |
2966 | + 'you must have set your launchpad login in bzr.') |
2967 | + self.vals['launchpad-login'] = login |
2968 | + |
2969 | + # Get the AWS identifier and secret identifier. |
2970 | + try: |
2971 | + credentials = EC2Credentials.load_from_file() |
2972 | + except CredentialsError, e: |
2973 | + self.error_and_quit(str(e)) |
2974 | + |
2975 | + # Make the EC2 connection. |
2976 | + controller = credentials.connect(self.name) |
2977 | + |
2978 | + # We do this here because it (1) cleans things up and (2) verifies |
2979 | + # that the account is correctly set up. Both of these are appropriate |
2980 | + # for initialization. |
2981 | + # |
2982 | + # We always recreate the keypairs because there is no way to |
2983 | + # programmatically retrieve the private key component, unless we |
2984 | + # generate it. |
2985 | + controller.delete_previous_key_pair() |
2986 | + |
2987 | + # get the image |
2988 | + image = controller.acquire_image(machine_id) |
2989 | + self._instance = EC2Instance( |
2990 | + self.name, image, instance_type, demo_networks, |
2991 | + controller, self.vals) |
2992 | + # now, as best as we can tell, we should be good to go. |
2993 | + |
2994 | + def error_and_quit(self, msg): |
2995 | + """Print error message and exit.""" |
2996 | + sys.stderr.write(msg) |
2997 | + sys.exit(1) |
2998 | + |
2999 | + def log(self, msg): |
3000 | + """Log a message on stdout, flushing afterwards.""" |
3001 | + # XXX: JonathanLange 2009-05-31 bug=383076: This should use Python |
3002 | + # logging, rather than printing to stdout. |
3003 | + sys.stdout.write(msg) |
3004 | + sys.stdout.flush() |
3005 | + |
3006 | + def start(self): |
3007 | + """Start the EC2 instance.""" |
3008 | + self._instance.start() |
3009 | + |
3010 | + def shutdown(self): |
3011 | + if self.headless and self._running: |
3012 | + self.log('letting instance run, to shut down headlessly ' |
3013 | + 'at completion of tests.\n') |
3014 | + return |
3015 | + return self._instance.shutdown() |
3016 | + |
3017 | + def configure_system(self): |
3018 | + # AS ROOT |
3019 | + self._instance.connect_as_root() |
3020 | + if self.vals['USER'] == 'gary': |
3021 | + # This helps gary debug problems others are having by removing |
3022 | + # much of the initial setup used to work on the original image. |
3023 | + self._instance.perform('deluser --remove-home gary', |
3024 | + ignore_failure=True) |
3025 | + p = self._instance.perform |
3026 | + # Let root perform sudo without a password. |
3027 | + p('echo "root\tALL=NOPASSWD: ALL" >> /etc/sudoers') |
3028 | + # Add the user. |
3029 | + p('adduser --gecos "" --disabled-password %(USER)s') |
3030 | + # Give user sudo without password. |
3031 | + p('echo "%(USER)s\tALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers') |
3032 | + # Make /var/launchpad owned by user. |
3033 | + p('chown -R %(USER)s:%(USER)s /var/launchpad') |
3034 | + # Clean out left-overs from the instance image. |
3035 | + p('rm -fr /var/tmp/*') |
3036 | + # Update the system. |
3037 | + p('aptitude update') |
3038 | + p('aptitude -y full-upgrade') |
3039 | + # Set up ssh for user |
3040 | + # Make user's .ssh directory |
3041 | + p('sudo -u %(USER)s mkdir /home/%(USER)s/.ssh') |
3042 | + sftp = self._instance.ssh.open_sftp() |
3043 | + remote_ssh_dir = '/home/%(USER)s/.ssh' % self.vals |
3044 | + # Create config file |
3045 | + self.log('Creating %s/config\n' % (remote_ssh_dir,)) |
3046 | + ssh_config_source = open(self.ssh_config_file_name) |
3047 | + config = SSHConfig() |
3048 | + config.parse(ssh_config_source) |
3049 | + ssh_config_source.close() |
3050 | + ssh_config_dest = sftp.open("%s/config" % remote_ssh_dir, 'w') |
3051 | + ssh_config_dest.write('CheckHostIP no\n') |
3052 | + ssh_config_dest.write('StrictHostKeyChecking no\n') |
3053 | + for hostname in ('devpad.canonical.com', 'chinstrap.canonical.com'): |
3054 | + ssh_config_dest.write('Host %s\n' % (hostname,)) |
3055 | + data = config.lookup(hostname) |
3056 | + for key in ('hostname', 'gssapiauthentication', 'proxycommand', |
3057 | + 'user', 'forwardagent'): |
3058 | + value = data.get(key) |
3059 | + if value is not None: |
3060 | + ssh_config_dest.write(' %s %s\n' % (key, value)) |
3061 | + ssh_config_dest.write('Host bazaar.launchpad.net\n') |
3062 | + ssh_config_dest.write(' user %(launchpad-login)s\n' % self.vals) |
3063 | + ssh_config_dest.close() |
3064 | + # create authorized_keys |
3065 | + self.log('Setting up %s/authorized_keys\n' % remote_ssh_dir) |
3066 | + authorized_keys_file = sftp.open( |
3067 | + "%s/authorized_keys" % remote_ssh_dir, 'w') |
3068 | + authorized_keys_file.write("%(key_type)s %(key)s\n" % self.vals) |
3069 | + authorized_keys_file.close() |
3070 | + sftp.close() |
3071 | + # Chown and chmod the .ssh directory and contents that we just |
3072 | + # created. |
3073 | + p('chown -R %(USER)s:%(USER)s /home/%(USER)s/') |
3074 | + p('chmod 644 /home/%(USER)s/.ssh/*') |
3075 | + self.log( |
3076 | + 'You can now use ssh -A %s to log in the instance.\n' % |
3077 | + self._instance.hostname) |
3078 | + # give the user permission to do whatever in /var/www |
3079 | + p('chown -R %(USER)s:%(USER)s /var/www') |
3080 | + self._instance.ssh.close() |
3081 | + |
3082 | + # AS USER |
3083 | + self._instance.connect_as_user() |
3084 | + sftp = self._instance.ssh.open_sftp() |
3085 | + # Set up bazaar.conf with smtp information if necessary |
3086 | + if self.email or self.message: |
3087 | + p('sudo -u %(USER)s mkdir /home/%(USER)s/.bazaar') |
3088 | + bazaar_conf_file = sftp.open( |
3089 | + "/home/%(USER)s/.bazaar/bazaar.conf" % self.vals, 'w') |
3090 | + bazaar_conf_file.write( |
3091 | + 'smtp_server = %(smtp_server)s\n' % self.vals) |
3092 | + if self.vals['smtp_username']: |
3093 | + bazaar_conf_file.write( |
3094 | + 'smtp_username = %(smtp_username)s\n' % self.vals) |
3095 | + if self.vals['smtp_password']: |
3096 | + bazaar_conf_file.write( |
3097 | + 'smtp_password = %(smtp_password)s\n' % self.vals) |
3098 | + bazaar_conf_file.close() |
3099 | + # Copy remote ec2-remote over |
3100 | + self.log('Copying ec2test-remote.py to remote machine.\n') |
3101 | + sftp.put( |
3102 | + os.path.join(os.path.dirname(os.path.realpath(__file__)), |
3103 | + 'ec2test-remote.py'), |
3104 | + '/var/launchpad/ec2test-remote.py') |
3105 | + sftp.close() |
3106 | + # Set up launchpad login and email |
3107 | + p('bzr launchpad-login %(launchpad-login)s') |
3108 | + p("bzr whoami '%(email)s'") |
3109 | + self._instance.ssh.close() |
3110 | + |
3111 | + def prepare_tests(self): |
3112 | + self._instance.connect_as_user() |
3113 | + # Clean up the test branch left in the instance image. |
3114 | + self._instance.perform('rm -rf /var/launchpad/test') |
3115 | + # get newest sources |
3116 | + self._instance.run_with_ssh_agent( |
3117 | + "rsync -avp --partial --delete " |
3118 | + "--filter='P *.o' --filter='P *.pyc' --filter='P *.so' " |
3119 | + "devpad.canonical.com:/code/rocketfuel-built/launchpad/sourcecode/* " |
3120 | + "/var/launchpad/sourcecode/") |
3121 | + # Get trunk. |
3122 | + self._instance.run_with_ssh_agent( |
3123 | + 'bzr branch %(trunk_branch)s /var/launchpad/test') |
3124 | + # Merge the branch in. |
3125 | + if self.vals['branch'] is not None: |
3126 | + self._instance.run_with_ssh_agent( |
3127 | + 'cd /var/launchpad/test; bzr merge %(branch)s') |
3128 | + else: |
3129 | + self.log('(Testing trunk, so no branch merge.)') |
3130 | + # Get any new sourcecode branches as requested |
3131 | + for dest, src in self.branches: |
3132 | + fulldest = os.path.join('/var/launchpad/test/sourcecode', dest) |
3133 | + if dest in ('canonical-identity-provider', 'shipit'): |
3134 | + # These two branches share some of the history with Launchpad. |
3135 | + # So we create a stacked branch on Launchpad so that the shared |
3136 | + # history isn't duplicated. |
3137 | + self._instance.run_with_ssh_agent( |
3138 | + 'bzr branch --no-tree --stacked %s %s' % |
3139 | + (TRUNK_BRANCH, fulldest)) |
3140 | + # The --overwrite is needed because they are actually two |
3141 | + # different branches (canonical-identity-provider was not |
3142 | + # branched off launchpad, but some revisions are shared.) |
3143 | + self._instance.run_with_ssh_agent( |
3144 | + 'bzr pull --overwrite %s -d %s' % (src, fulldest)) |
3145 | + # The third line is necessary because of the --no-tree option |
3146 | + # used initially. --no-tree doesn't create a working tree. |
3147 | + # It only works with the .bzr directory (branch metadata and |
3148 | + # revisions history). The third line creates a working tree |
3149 | + # based on the actual branch. |
3150 | + self._instance.run_with_ssh_agent( |
3151 | + 'bzr checkout "%s" "%s"' % (fulldest, fulldest)) |
3152 | + else: |
3153 | + # The "--standalone" option is needed because some branches |
3154 | + # are/were using a different repository format than Launchpad |
3155 | + # (bzr-svn branch for example). |
3156 | + self._instance.run_with_ssh_agent( |
3157 | + 'bzr branch --standalone %s %s' % (src, fulldest)) |
3158 | + # prepare fresh copy of sourcecode and buildout sources for building |
3159 | + p = self._instance.perform |
3160 | + p('rm -rf /var/launchpad/tmp') |
3161 | + p('mkdir /var/launchpad/tmp') |
3162 | + p('cp -R /var/launchpad/sourcecode /var/launchpad/tmp/sourcecode') |
3163 | + p('mkdir /var/launchpad/tmp/eggs') |
3164 | + self._instance.run_with_ssh_agent( |
3165 | + 'bzr co lp:lp-source-dependencies ' |
3166 | + '/var/launchpad/tmp/download-cache') |
3167 | + if (self.include_download_cache_changes and |
3168 | + self.download_cache_additions): |
3169 | + sftp = self._instance.ssh.open_sftp() |
3170 | + root = os.path.realpath( |
3171 | + os.path.join(self.original_branch, 'download-cache')) |
3172 | + for info in self.download_cache_additions: |
3173 | + src = os.path.join(root, info[0]) |
3174 | + self.log('Copying %s to remote machine.\n' % (src,)) |
3175 | + sftp.put( |
3176 | + src, |
3177 | + os.path.join('/var/launchpad/tmp/download-cache', info[0])) |
3178 | + sftp.close() |
3179 | + p('/var/launchpad/test/utilities/link-external-sourcecode ' |
3180 | + '-p/var/launchpad/tmp -t/var/launchpad/test'), |
3181 | + # set up database |
3182 | + p('/var/launchpad/test/utilities/launchpad-database-setup %(USER)s') |
3183 | + p('cd /var/launchpad/test && make build') |
3184 | + p('cd /var/launchpad/test && make schema') |
3185 | + # close ssh connection |
3186 | + self._instance.ssh.close() |
3187 | + |
3188 | + def start_demo_webserver(self): |
3189 | + """Turn ec2 instance into a demo server.""" |
3190 | + self._instance.connect_as_user() |
3191 | + p = self._instance.perform |
3192 | + p('mkdir -p /var/tmp/bazaar.launchpad.dev/static') |
3193 | + p('mkdir -p /var/tmp/bazaar.launchpad.dev/mirrors') |
3194 | + p('sudo a2enmod proxy > /dev/null') |
3195 | + p('sudo a2enmod proxy_http > /dev/null') |
3196 | + p('sudo a2enmod rewrite > /dev/null') |
3197 | + p('sudo a2enmod ssl > /dev/null') |
3198 | + p('sudo a2enmod deflate > /dev/null') |
3199 | + p('sudo a2enmod headers > /dev/null') |
3200 | + # Install apache config file. |
3201 | + p('cd /var/launchpad/test/; sudo make install') |
3202 | + # Use raw string to eliminate the need to escape the backslash. |
3203 | + # Put eth0's ip address in the /tmp/ip file. |
3204 | + p(r"ifconfig eth0 | grep 'inet addr' " |
3205 | + r"| sed -re 's/.*addr:([0-9.]*) .*/\1/' > /tmp/ip") |
3206 | + # Replace 127.0.0.88 in Launchpad's apache config file with the |
3207 | + # ip address just stored in the /tmp/ip file. Perl allows for |
3208 | + # inplace editing unlike sed. |
3209 | + p('sudo perl -pi -e "s/127.0.0.88/$(cat /tmp/ip)/g" ' |
3210 | + '/etc/apache2/sites-available/local-launchpad') |
3211 | + # Restart apache. |
3212 | + p('sudo /etc/init.d/apache2 restart') |
3213 | + # Build mailman and minified javascript, etc. |
3214 | + p('cd /var/launchpad/test/; make') |
3215 | + # Start launchpad in the background. |
3216 | + p('cd /var/launchpad/test/; make start') |
3217 | + # close ssh connection |
3218 | + self._instance.ssh.close() |
3219 | + |
3220 | + def run_tests(self): |
3221 | + self._instance.connect_as_user() |
3222 | + |
3223 | + # Make sure we activate the failsafe --shutdown feature. This will |
3224 | + # make the server shut itself down after the test run completes, or |
3225 | + # if the test harness suffers a critical failure. |
3226 | + cmd = ['python /var/launchpad/ec2test-remote.py --shutdown'] |
3227 | + |
3228 | + # Do we want to email the results to the user? |
3229 | + if self.email: |
3230 | + for email in self.email: |
3231 | + cmd.append("--email='%s'" % ( |
3232 | + email.encode('utf8').encode('string-escape'),)) |
3233 | + |
3234 | + # Do we want to submit the branch to PQM if the tests pass? |
3235 | + if self.message is not None: |
3236 | + cmd.append( |
3237 | + "--submit-pqm-message='%s'" % ( |
3238 | + pickle.dumps( |
3239 | + self.message).encode( |
3240 | + 'base64').encode('string-escape'),)) |
3241 | + |
3242 | + # Do we want to disconnect the terminal once the test run starts? |
3243 | + if self.headless: |
3244 | + cmd.append('--daemon') |
3245 | + |
3246 | + # Which branch do we want to test? |
3247 | + if self.vals['branch'] is not None: |
3248 | + branch = self.vals['branch'] |
3249 | + remote_branch = Branch.open(branch) |
3250 | + branch_revno = remote_branch.revno() |
3251 | + else: |
3252 | + branch = self.vals['trunk_branch'] |
3253 | + branch_revno = None |
3254 | + cmd.append('--public-branch=%s' % branch) |
3255 | + if branch_revno is not None: |
3256 | + cmd.append('--public-branch-revno=%d' % branch_revno) |
3257 | + |
3258 | + # Add any additional options for ec2test-remote.py |
3259 | + cmd.extend(self.get_remote_test_options()) |
3260 | + self.log( |
3261 | + 'Running tests... (output is available on ' |
3262 | + 'http://%s/)\n' % self._instance.hostname) |
3263 | + |
3264 | + # Try opening a browser pointed at the current test results. |
3265 | + if self.open_browser: |
3266 | + try: |
3267 | + import webbrowser |
3268 | + except ImportError: |
3269 | + self.log("Could not open web browser due to ImportError.") |
3270 | + else: |
3271 | + status = webbrowser.open(self._instance.hostname) |
3272 | + if not status: |
3273 | + self.log("Could not open web browser.") |
3274 | + |
3275 | + # Run the remote script! Our execution will block here until the |
3276 | + # remote side disconnects from the terminal. |
3277 | + self._instance.perform(' '.join(cmd)) |
3278 | + self._running = True |
3279 | + |
3280 | + if not self.headless: |
3281 | + sftp = self._instance.ssh.open_sftp() |
3282 | + # We ran to completion locally, so we'll be in charge of shutting |
3283 | + # down the instance, in case the user has requested a postmortem. |
3284 | + # |
3285 | + # We only have 60 seconds to do this before the remote test |
3286 | + # script shuts the server down automatically. |
3287 | + self._instance.perform( |
3288 | + 'kill `cat /var/launchpad/ec2test-remote.pid`') |
3289 | + |
3290 | + # deliver results as requested |
3291 | + if self.file: |
3292 | + self.log( |
3293 | + 'Writing abridged test results to %s.\n' % self.file) |
3294 | + sftp.get('/var/www/summary.log', self.file) |
3295 | + sftp.close() |
3296 | + # close ssh connection |
3297 | + self._instance.ssh.close() |
3298 | + |
3299 | + def get_remote_test_options(self): |
3300 | + """Return the test command that will be passed to ec2test-remote.py. |
3301 | + |
3302 | + Returns a tuple of command-line options and switches. |
3303 | + """ |
3304 | + if '--jscheck' in self.test_options: |
3305 | + # We want to run the JavaScript test suite. |
3306 | + return ('--jscheck',) |
3307 | + else: |
3308 | + # Run the normal testsuite with our Zope testrunner options. |
3309 | + # ec2test-remote.py wants the extra options to be after a double- |
3310 | + # dash. |
3311 | + return ('--', self.test_options) |
3312 | |
3313 | === added file 'lib/devscripts/sourcecode.py' |
3314 | --- lib/devscripts/sourcecode.py 1970-01-01 00:00:00 +0000 |
3315 | +++ lib/devscripts/sourcecode.py 2009-09-10 00:44:54 +0000 |
3316 | @@ -0,0 +1,83 @@ |
3317 | +# Copyright 2009 Canonical Ltd. This software is licensed under the |
3318 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
3319 | + |
3320 | +"""Tools for maintaining the Launchpad source code.""" |
3321 | + |
3322 | +__metaclass__ = type |
3323 | +__all__ = [ |
3324 | + 'interpret_config', |
3325 | + 'parse_config_file', |
3326 | + 'plan_update', |
3327 | + ] |
3328 | + |
3329 | +import os |
3330 | + |
3331 | +from bzrlib.bzrdir import BzrDir |
3332 | +from bzrlib.transport import get_transport |
3333 | + |
3334 | + |
3335 | +def parse_config_file(file_handle): |
3336 | + """Parse the source code config file 'file_handle'. |
3337 | + |
3338 | + :param file_handle: A file-like object containing sourcecode |
3339 | + configuration. |
3340 | + :return: A sequence of lines of either '[key, value]' or |
3341 | + '[key, value, optional]'. |
3342 | + """ |
3343 | + for line in file_handle: |
3344 | + if line.startswith('#'): |
3345 | + continue |
3346 | + yield [token.strip() for token in line.split('=')] |
3347 | + |
3348 | + |
3349 | +def interpret_config_entry(entry): |
3350 | + """Interpret a single parsed line from the config file.""" |
3351 | + return (entry[0], (entry[1], len(entry) > 2)) |
3352 | + |
3353 | + |
3354 | +def interpret_config(config_entries): |
3355 | + """Interpret a configuration stream, as parsed by 'parse_config_file'. |
3356 | + |
3357 | + :param configuration: A sequence of parsed configuration entries. |
3358 | + :return: A dict mapping the names of the sourcecode dependencies to a |
3359 | + 2-tuple of their branches and whether or not they are optional. |
3360 | + """ |
3361 | + return dict(map(interpret_config_entry, config_entries)) |
3362 | + |
3363 | + |
3364 | +def _subset_dict(d, keys): |
3365 | + """Return a dict that's a subset of 'd', based on the keys in 'keys'.""" |
3366 | + return dict((key, d[key]) for key in keys) |
3367 | + |
3368 | + |
3369 | +def plan_update(existing_branches, configuration): |
3370 | + """Plan the update to existing branches based on 'configuration'. |
3371 | + |
3372 | + :param existing_branches: A sequence of branches that already exist. |
3373 | + :param configuration: A dictionary of sourcecode configuration, such as is |
3374 | + returned by `interpret_config`. |
3375 | + :return: (new_branches, update_branches, removed_branches), where |
3376 | + 'new_branches' are the branches in the configuration that don't exist |
3377 | + yet, 'update_branches' are the branches in the configuration that do |
3378 | + exist, and 'removed_branches' are the branches that exist locally, but |
3379 | + not in the configuration. 'new_branches' and 'update_branches' are |
3380 | + dicts of the same form as 'configuration', 'removed_branches' is a |
3381 | + set of the same form as 'existing_branches'. |
3382 | + """ |
3383 | + existing_branches = set(existing_branches) |
3384 | + config_branches = set(configuration.keys()) |
3385 | + new_branches = config_branches - existing_branches |
3386 | + removed_branches = existing_branches - config_branches |
3387 | + update_branches = config_branches.intersection(existing_branches) |
3388 | + return ( |
3389 | + _subset_dict(configuration, new_branches), |
3390 | + _subset_dict(configuration, update_branches), |
3391 | + removed_branches) |
3392 | + |
3393 | + |
3394 | +def find_branches(directory): |
3395 | + """List the directory names in 'directory' that are branches.""" |
3396 | + transport = get_transport(directory) |
3397 | + return ( |
3398 | + os.path.basename(branch.base.rstrip('/')) |
3399 | + for branch in BzrDir.find_branches(transport)) |
3400 | |
3401 | === added directory 'lib/devscripts/tests' |
3402 | === added file 'lib/devscripts/tests/__init__.py' |
3403 | --- lib/devscripts/tests/__init__.py 1970-01-01 00:00:00 +0000 |
3404 | +++ lib/devscripts/tests/__init__.py 2009-09-09 03:27:47 +0000 |
3405 | @@ -0,0 +1,4 @@ |
3406 | +# Copyright 2009 Canonical Ltd. This software is licensed under the |
3407 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
3408 | + |
3409 | +"""Tests for devscripts.""" |
3410 | |
3411 | === added file 'lib/devscripts/tests/test_sourcecode.py' |
3412 | --- lib/devscripts/tests/test_sourcecode.py 1970-01-01 00:00:00 +0000 |
3413 | +++ lib/devscripts/tests/test_sourcecode.py 2009-09-10 00:44:21 +0000 |
3414 | @@ -0,0 +1,187 @@ |
3415 | +# Copyright 2009 Canonical Ltd. This software is licensed under the |
3416 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
3417 | + |
3418 | +"""Module docstring goes here.""" |
3419 | + |
3420 | +__metaclass__ = type |
3421 | + |
3422 | +import shutil |
3423 | +from StringIO import StringIO |
3424 | +import tempfile |
3425 | +import unittest |
3426 | + |
3427 | +from bzrlib.bzrdir import BzrDir |
3428 | +from bzrlib.tests import TestCase |
3429 | +from bzrlib.transport import get_transport |
3430 | + |
3431 | +from devscripts.sourcecode import ( |
3432 | + find_branches, interpret_config, parse_config_file, plan_update) |
3433 | + |
3434 | + |
3435 | +class TestParseConfigFile(unittest.TestCase): |
3436 | + """Tests for the config file parser.""" |
3437 | + |
3438 | + def makeFile(self, contents): |
3439 | + return StringIO(contents) |
3440 | + |
3441 | + def test_empty(self): |
3442 | + # Parsing an empty config file returns an empty sequence. |
3443 | + empty_file = self.makeFile("") |
3444 | + self.assertEqual([], list(parse_config_file(empty_file))) |
3445 | + |
3446 | + def test_single_value(self): |
3447 | + # Parsing a file containing a single key=value pair returns a sequence |
3448 | + # containing the (key, value) as a list. |
3449 | + config_file = self.makeFile("key=value") |
3450 | + self.assertEqual( |
3451 | + [['key', 'value']], list(parse_config_file(config_file))) |
3452 | + |
3453 | + def test_comment_ignored(self): |
3454 | + # If a line begins with a '#', then its a comment. |
3455 | + comment_only = self.makeFile('# foo') |
3456 | + self.assertEqual([], list(parse_config_file(comment_only))) |
3457 | + |
3458 | + def test_optional_value(self): |
3459 | + # Lines in the config file can have a third optional entry. |
3460 | + config_file = self.makeFile('key=value=optional') |
3461 | + self.assertEqual( |
3462 | + [['key', 'value', 'optional']], |
3463 | + list(parse_config_file(config_file))) |
3464 | + |
3465 | + def test_whitespace_stripped(self): |
3466 | + # Any whitespace around any of the tokens in the config file are |
3467 | + # stripped out. |
3468 | + config_file = self.makeFile(' key = value = optional ') |
3469 | + self.assertEqual( |
3470 | + [['key', 'value', 'optional']], |
3471 | + list(parse_config_file(config_file))) |
3472 | + |
3473 | + |
3474 | +class TestInterpretConfiguration(unittest.TestCase): |
3475 | + """Tests for the configuration interpreter.""" |
3476 | + |
3477 | + def test_empty(self): |
3478 | + # An empty configuration stream means no configuration. |
3479 | + config = interpret_config([]) |
3480 | + self.assertEqual({}, config) |
3481 | + |
3482 | + def test_key_value(self): |
3483 | + # A (key, value) pair without a third optional value is returned in |
3484 | + # the configuration as a dictionary entry under 'key' with '(value, |
3485 | + # False)' as its value. |
3486 | + config = interpret_config([['key', 'value']]) |
3487 | + self.assertEqual({'key': ('value', False)}, config) |
3488 | + |
3489 | + def test_key_value_optional(self): |
3490 | + # A (key, value, optional) entry is returned in the configuration as a |
3491 | + # dictionary entry under 'key' with '(value, True)' as its value. |
3492 | + config = interpret_config([['key', 'value', 'optional']]) |
3493 | + self.assertEqual({'key': ('value', True)}, config) |
3494 | + |
3495 | + |
3496 | +class TestPlanUpdate(unittest.TestCase): |
3497 | + """Tests for how to plan the update.""" |
3498 | + |
3499 | + def test_trivial(self): |
3500 | + # In the trivial case, there are no existing branches and no |
3501 | + # configured branches, so there are no branches to add, none to |
3502 | + # update, and none to remove. |
3503 | + new, existing, removed = plan_update([], {}) |
3504 | + self.assertEqual({}, new) |
3505 | + self.assertEqual({}, existing) |
3506 | + self.assertEqual(set(), removed) |
3507 | + |
3508 | + def test_all_new(self): |
3509 | + # If there are no existing branches, then the all of the configured |
3510 | + # branches are new, none are existing and none have been removed. |
3511 | + new, existing, removed = plan_update([], {'a': ('b', False)}) |
3512 | + self.assertEqual({'a': ('b', False)}, new) |
3513 | + self.assertEqual({}, existing) |
3514 | + self.assertEqual(set(), removed) |
3515 | + |
3516 | + def test_all_old(self): |
3517 | + # If there configuration is now empty, but there are existing |
3518 | + # branches, then that means all the branches have been removed from |
3519 | + # the configuration, none are new and none are updated. |
3520 | + new, existing, removed = plan_update(['a', 'b', 'c'], {}) |
3521 | + self.assertEqual({}, new) |
3522 | + self.assertEqual({}, existing) |
3523 | + self.assertEqual(set(['a', 'b', 'c']), removed) |
3524 | + |
3525 | + def test_all_same(self): |
3526 | + # If the set of existing branches is the same as the set of |
3527 | + # non-existing branches, then they all need to be updated. |
3528 | + config = {'a': ('b', False), 'c': ('d', True)} |
3529 | + new, existing, removed = plan_update(config.keys(), config) |
3530 | + self.assertEqual({}, new) |
3531 | + self.assertEqual(config, existing) |
3532 | + self.assertEqual(set(), removed) |
3533 | + |
3534 | + |
3535 | +class TestFindBranches(TestCase): |
3536 | + """Tests the way that we find branches.""" |
3537 | + |
3538 | + def makeBranch(self, path): |
3539 | + transport = get_transport(path) |
3540 | + transport.ensure_base() |
3541 | + BzrDir.create_branch_convenience( |
3542 | + transport.base, possible_transports=[transport]) |
3543 | + |
3544 | + def makeDirectory(self): |
3545 | + directory = tempfile.mkdtemp() |
3546 | + self.addCleanup(shutil.rmtree, directory) |
3547 | + return directory |
3548 | + |
3549 | + def test_empty_directory_has_no_branches(self): |
3550 | + # An empty directory has no branches. |
3551 | + empty = self.makeDirectory() |
3552 | + self.assertEqual([], list(find_branches(empty))) |
3553 | + |
3554 | + def test_directory_with_branches(self): |
3555 | + # find_branches finds branches in the directory. |
3556 | + directory = self.makeDirectory() |
3557 | + self.makeBranch('%s/a' % directory) |
3558 | + self.assertEqual(['a'], list(find_branches(directory))) |
3559 | + |
3560 | + def test_ignores_files(self): |
3561 | + # find_branches ignores any files in the directory. |
3562 | + directory = self.makeDirectory() |
3563 | + some_file = open('%s/a' % directory, 'w') |
3564 | + some_file.write('hello\n') |
3565 | + some_file.close() |
3566 | + self.assertEqual([], list(find_branches(directory))) |
3567 | + |
3568 | + |
3569 | +# XXX: Actually remove branches |
3570 | + |
3571 | +# XXX: Update existing branches |
3572 | + |
3573 | +# XXX: Branch new branches |
3574 | + |
3575 | +# XXX: Should we parallelize? If so, how? Can we rely on Twisted being |
3576 | +# present. |
3577 | + |
3578 | +# XXX: Find the sourcecode directory. |
3579 | + |
3580 | +# XXX: Handle symlinks. Lots of people manage sourcecode via symlinks |
3581 | + |
3582 | +# XXX: Actual storage location can be inferred from symlinks, since presumably |
3583 | +# they link into the actual store. However, some of the symlinks might differ |
3584 | +# (because of developers fiddling with things). We can take a survey of all of |
3585 | +# them, and choose the most popular. |
3586 | + |
3587 | +# XXX: How to report errors? |
3588 | + |
3589 | +# XXX: rocketfuel-get does stacking onto launchpad for some branches. Should |
3590 | +# we actually do this? It seems a bit silly if in a shared repo. (Although |
3591 | +# does the branch mask the shared repo bit?) |
3592 | + |
3593 | +# XXX: Can we get rocketfuel-setup to use this? |
3594 | + |
3595 | +# XXX: (unrelated). Finding the highest common ancestor is easy, but what if |
3596 | +# you have two (N?) trees? Is there a way to algorithmically find the two (N?) |
3597 | +# interesting HCAs? Can the question even be framed well? |
3598 | + |
3599 | + |
3600 | +def test_suite(): |
3601 | + return unittest.TestLoader().loadTestsFromName(__name__) |
3602 | |
3603 | === added file 'utilities/ec2test.py' |
3604 | --- utilities/ec2test.py 1970-01-01 00:00:00 +0000 |
3605 | +++ utilities/ec2test.py 2009-09-10 05:21:20 +0000 |
3606 | @@ -0,0 +1,13 @@ |
3607 | +#!/usr/bin/python2.4 |
3608 | + |
3609 | +# Copyright 2009 Canonical Ltd. This software is licensed under the |
3610 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
3611 | + |
3612 | +"""Executable for the ec2test script.""" |
3613 | + |
3614 | +__metaclass__ = type |
3615 | +__all__ = [] |
3616 | + |
3617 | +import _pythonpath |
3618 | +from devscripts.ec2test import main |
3619 | +main() |
Hi jml,
You know what this branch is about :)
Cheers,
mwh