Merge lp:~mwhudson/launchpad/libraryize-ec2test into lp:launchpad

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
Reviewer Review Type Date Requested Status
Jonathan Lange (community) Needs Fixing
Review via email: mp+11568@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Michael Hudson-Doyle (mwhudson) wrote :

Hi jml,

You know what this branch is about :)

Cheers,
mwh

Revision history for this message
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/ec2test.py script needs an __all__. Could you please remove it?

Obviously, landing this branch depends on fixing up & landing my better-sourcecode-update branch.

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()