Merge lp:~yellow/launchpad/lxcsetup into lp:launchpad

Proposed by Francesco Banconi
Status: Merged
Approved by: Francesco Banconi
Approved revision: no longer in the source branch.
Merged at revision: 14733
Proposed branch: lp:~yellow/launchpad/lxcsetup
Merge into: lp:launchpad
Diff against target: 823 lines (+819/-0)
1 file modified
utilities/setuplxc.py (+819/-0)
To merge this branch: bzr merge lp:~yellow/launchpad/lxcsetup
Reviewer Review Type Date Requested Status
Benji York (community) code Approve
Graham Binns Pending
Review via email: mp+89660@code.launchpad.net

Description of the change

= Summary =

This branch adds a script that can be used to set up a Launchpad environment
inside a LXC, useful for testing Launchpad.

== Proposed fix ==

https://dev.launchpad.net/ParallelTests describes how LXC containers offer
a cheap way to run tests in parallel using ephemeral instances, obtaining the
required isolation to workaround the existing globals (shared work dirs,
hardcoded tcp ports, etc.). The `setuplxc.py` script creates a Launchpad
environment from scratch, ready to be used by ephemerals (e.g. in a buildbot
slave context).

== Pre-implementation notes ==

During development we realized that the same approach (with few changes)
should work to set up a Launchpad development environment.
The developer can just:

- run the script (passing his current local username as user), e.g.::

    ./setuplxc.py -u username -e <email address hidden> -n 'Firstname Lastname'
    -c lp-devel -d /home/username/lp-deps /home/username/lp-branches/

- ssh into the container (in the example above: ssh lp-devel)
- cd ~/lp-branches/devel
- make schema
- make run
- start hacking

== Implementation details ==

utilities/setuplxc.py:
* the script is implemented in Python
* requires Python 2.7
* must be run as root
* help on required arguments: utilities/setuplxc.py -h

utilities/launchpad-database-setup
* refactored so that it can be run by root

== Tests ==

The script uses internal helper functions providing doctests. To run them::

     python -m doctest -v utilities/setuplxc.py

A more extensive and complete testing approach would require to setup
a precise KVM.

== Demo and Q/A ==

To demo and Q/A this change, do the following:

 * Install precise in a virtual machine (e.g. KVM)

 * Copy the script inside the virtual machine, e.g. for kvm::

    kvm -hda precise_HDA.img -boot c -m 2000 -redir tcp:2222::22 &
    scp -P 2222 /utilities/setuplxc.py root@localhost:/tmp/

 * Run the script, e.g.::

    ssh -p 2222 root@localhost
    cd /tmp/
    ./setuplxc [arguments]

== lint ==

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  utilities/setuplxc.py

To post a comment you must log in.
Revision history for this message
Benji York (benji) wrote :

Here's what I have this far:

lxc-create and lxc-start use the "-n" option to specify the instance
name, we might want to do the same.

It would be nice if the script used the current user info (login name)
if none are provided as arguments. Taking that a step further, bzr
whoami produces consistent, easily parsable results that could make
--email and --name optional as well.

...Later: oh! The user info is required because the script is expecting
to be run as root. I suppose the easiest thing would be to leave it
as-is, but it'd be really nice to not have to provide all of those
arguments. Perhaps the script can be re factored into two halves: the
first starts as the user in question, and the second part is run under
sudo (prompting the user for a password if nectary).

I'm a big fan of doctest, but it's LP policy to not introduce any new
(non-documentation) doctests, so the embedded doctests should be
translated into unittest-style tests.

Is it really necessary to skip the "sudo" (in launchpad-database-setup)
if already running as root? I would expect the sudo to be a noop in
that scenario. Similarly, "sudo -u postgres" should work equally well
for root as it does for non-root users.

We've mostly transitioned away from the %-style string construction and
switched to the new .format() string method so the script should avoid
constructing strings with %.

On line 210 of the diff: The summary line of a docstring should fit on a
single line.

The file_append function doesn't have to rewrite the entire file.
Instead the file can be opened in append mode and the new line appended.

Also, when appending a line to a file you should check to see if the
last line of the file doesn't have a trailing newline, otherwise you can
end up adding your string to the last line of the file instead of
appending an entirely new line.

Since this script requires Python 2.7, I would use the print function
instead of the print keyword (from __future__ import print_function).

On line 366 of the diff, the description of the --name argument leaves
off the "r" in "for" ("used fo bzr").

The parenthesis around the multi-line constructions of the "help"
arguments aren't necessary.

Requiring an SSH private key without passphrase gives me some security
shivers and may raise a few eyebrows.

Revision history for this message
Gary Poster (gary) wrote :

I'm a branch participant too, so I'll have a small reply. My thoughts, fwiw:

- very helpful review. I'm old-skool with %-based string interpolation, I guess. That came from me, I think. I'm fine with format.
- Given that this script is standalone, I thought that the doctests were fine when I saw them. Separating them from the script would be a shame IMO
- file_append wants to insert a line at the beginning IIRC, so rewriting makes sense. Maybe I don't RC.
- print function from future: eh. If Francesco wants IMO :-)
- requiring an SSH private key without passphrase is necessary for this to be doable mechanically, such as part of a juju install, AFAIK. If there's another approach, I'd be very interested in hearing of it.

Revision history for this message
Martin Pool (mbp) wrote :

I'm happy to see this, but it is a bit unfortunate how much code is
getting duplicated between ec2, rocketfuel-setup, and now this...
Perhaps it can later be split out to scripts that are used by all of
them.

Revision history for this message
Gary Poster (gary) wrote :

Martin, we do have a slack-time card to eliminate duplication between this and rocketfuel-setup.

That said, the problem with that task, and with similar work for ec2, is that IMO rocketfuel-setup and setuplxc should be standalone--someone should be able to get the file, without the Launchpad tree, and the script should be able to run. It doesn't have to be a file, but that's the easiest unit to work with for our use cases, particularly for setuplxc. I'd be tempted to have rocketfuel-setup depend on setuplxc rather than reverse, but that's painting a bikeshed.

An alternative approach would be to have a deb package that encompasses all three, so instead of "get this script and run it" you have "[install this PPA, ] install this package and run it." Our to-be-developed buildbot slave juju charm that wants to use setuplxc could have a configuration option to install PPAs and packages, in addition to or instead of accepting files to run. That sounds interesting to me, though it's out of scope for the current project. Could be a slack/20% time project though, maybe.

Revision history for this message
Benji York (benji) wrote :

Here's the rest of my review:

The places that have comments like "This requires Oneiric or later." or
"This script is run as root." could assert those preconditions and
generate errors if they are not met.

A question about file_insert and file_append: are the files being
modified long-lived? If we are using file_append on a file and the user
adds a new line to the end of the file then we will re-append another
copy of our line(s) to that file. Do those lines really have to be at
the very beginning and very end? Could we instead check to see if they
exist anywhere in the file and append/prepend only if they don't?

Writing the above made me realize that file_insert should probably be
named file_prepend since its behavior is the mirror of file_append and
doesn't insert the line at an arbitrary point.

Tiny suggestion: I think it would read better if you used

    with ssh_connection(lxcname) as ssh:
        ...

instead of

    with ssh(lxcname) as sshcall:
        ...

in initialize_lxc. The later "ssh(...)" calls would read better as
well.

We should have some error checking/reporting for all the commands we're
running (via SSH). Just something simple to stderr would help
tremendously for that inevitable time in the future when the script
fails for one reason or another.

What purpose does the time.sleep(5) in stop_lxc serve? Is it intended
as a deadline for the instance to stop cleanly? If so, it seems fine
(if a little short). We could increase it to account for the inevitable
slow shutdowns that will sometimes happen and add a probe in a loop to
see if the instance has actually stopped or not that can exit early if
the instance stops before the timeout value is reached (much as you do
in create_lxc).

On the other hand, since these are ephemeral instances, perhaps we don't
really care to let them shut down cleanly, in that case we could just
lxc-stop them without the poweroff.

Name of the Namespace class could be a bit more specific (line 584 of
the diff). Perhaps "Configuration".

Revision history for this message
Francesco Banconi (frankban) wrote :

> A question about file_insert and file_append: are the files being
> modified long-lived? If we are using file_append on a file and the user
> adds a new line to the end of the file then we will re-append another
> copy of our line(s) to that file. Do those lines really have to be at
> the very beginning and very end? Could we instead check to see if they
> exist anywhere in the file and append/prepend only if they don't?

The check is already done. However, I can improve the way it is done: file_insert checks only for the first line of the file, it could do it for the entire file, remove the line if present, put the line on top.
The order of the lines is important, for example in resolv.conf we need to make sure that the lxc bridge nameserver is the first.
s/file_insert/file_prepend: +1

> What purpose does the time.sleep(5) in stop_lxc serve? Is it intended
> as a deadline for the instance to stop cleanly? If so, it seems fine
> (if a little short). We could increase it to account for the inevitable
> slow shutdowns that will sometimes happen and add a probe in a loop to
> see if the instance has actually stopped or not that can exit early if
> the instance stops before the timeout value is reached (much as you do
> in create_lxc).
>
> On the other hand, since these are ephemeral instances, perhaps we don't
> really care to let them shut down cleanly, in that case we could just
> lxc-stop them without the poweroff.

I agree on creating a loop that checks if the container is stopped for, say, 60 seconds.
The lxc will be used as a template for ephemerals, or as a Launchpad environment for developers, so, in my opinion, lxc-stop is not sufficient.

Many thanks Benji for your detailed review, working on it.

Revision history for this message
Benji York (benji) wrote :

The script looks great, don't let the wall of text below fool you. :)

All of the points below can be ignored if you so desire, except for the
last three paragraphs. All these things seemed important enough to
note, but not important enough to block on if you don't agree. The last
three paragraphs probably do need to be addressed though. I'm confident
you'll do that so I've approved the MP.

I lost my first draft of this, so I'm afraid this is more terse than I
would like.

Since this is a stand-alone script, we don't need to populate __all__.

The leading underscores in function names aren't needed (since we use
__all__ to document a module's exported names we don't have to ugly up
names that we don't want people to use).

We can use email.Utils.parseaddr instead of _parse_whoami.

I assume the "parser" argument for bzr_whois was intended as a
dependency injection point, but since it's not used it can be removed.

A small thing, but it seems to me that

    if not content.endswith('\n'):
        f.write('\n')
    f.write(line)

would be a bit easier to read than

    f.write(line if content.endswith('\n') else '\n{}'.format(line))

Since "ssh" doesn't perform any cleanup actions (i.e., there is no code
after the yield), it could just be a regular function.

There are a few instances of code like this:

    'Error running command: {}'.format(' '.join(sshcmd))

A slight simplification would be to use string concatenation instead:

    'Error running command: ' + ' '.join(sshcmd)

I don't understand the meaning of "clean" in _clean_users,
_clean_userdata, and _clean_ssh_keys. Maybe it's an assertion that they
are clean, in that case I'd expect the functions to be named
"validate...", but they do more than validation, so that doesn't sounnd
right either. Maybe "set" would be the right word ("set_directories",
etc.).

You can use email.Utils.formataddr instead of string operations in
initialize_host to construct the user's whois string.

The os.system call in initialize_host will do the wrong thing if
checkout_dir contains a space (or quotation mark, etc.).
subprocess.call might be a better choice there.

If a function_args_map action raises an exception in main, the exception
is returned (not reraised) and the return value of main is passed to
sys.exit which expects an integer. That doesn't seem quite right.
Perhaps main should just return 1 if an exception occurs.

The dance to restart as root deserves a couple lines of commentary
explaining that if we weren't started as root then we gather up all the
info we need about the user and then restart as root. (I really like
this feature, by the way).

review: Approve (code)
Revision history for this message
Francesco Banconi (frankban) wrote :

> The script looks great, don't let the wall of text below fool you. :)

Hooray!
My thoughts on some of your observations:

> We can use email.Utils.parseaddr instead of _parse_whoami.

I agree, I didn't think about looking at the standard library :-(

> I assume the "parser" argument for bzr_whois was intended as a
> dependency injection point, but since it's not used it can be removed.

It is, see line 127 of the diff.

> Since "ssh" doesn't perform any cleanup actions (i.e., there is no code
> after the yield), it could just be a regular function.

Yes, my same doubt. I ended up using a context manager just to separate connection arguments from the real "call" argument, and to avoid repeating username and location on each ssh call.

> I don't understand the meaning of "clean" in _clean_users,
> _clean_userdata, and _clean_ssh_keys. Maybe it's an assertion that they
> are clean, in that case I'd expect the functions to be named
> "validate...", but they do more than validation, so that doesn't sounnd
> right either. Maybe "set" would be the right word ("set_directories",
> etc.).

Argh... naming things... I used "clean" as e verb, because after calling them we can assume the namespace to be "clean", usable. Maybe "handle_*" could be better?

> If a function_args_map action raises an exception in main, the exception
> is returned (not reraised) and the return value of main is passed to
> sys.exit which expects an integer. That doesn't seem quite right.

sys.exit expects other types too, and in that case it prints the string representation of the passed object to stderr and then exits with 1, that's what we want.

Thank you Benji for all your suggestions.

Revision history for this message
Benji York (benji) wrote :

> > I assume the "parser" argument for bzr_whois was intended as a
> > dependency injection point, but since it's not used it can be removed.
>
> It is, see line 127 of the diff.

I'm not seeing it, but I'll take your word for it. Oh! I think you
mean that the parameter is used, right I see that; I mean that the
*argument* is never used, i.e., no caller of bzr_whois ever passes an
argument for "parser", therefore it can be removed and its use in the
body of bzr_whois can be replaced with parseaddr.

> > Since "ssh" doesn't perform any cleanup actions (i.e., there is no code
> > after the yield), it could just be a regular function.
>
> Yes, my same doubt. I ended up using a context manager just to separate
> connection arguments from the real "call" argument, and to avoid repeating
> username and location on each ssh call.

The function can still return a callable so you get those same benefits.
The transformation would be to remove the decorator and change the yield
into a return. Then instead of doing a "with ssh(...) as foo" you'd do
a "foo = ssh(...)".

> > I don't understand the meaning of "clean" in _clean_users,
> > _clean_userdata, and _clean_ssh_keys. Maybe it's an assertion that they
> > are clean, in that case I'd expect the functions to be named
> > "validate...", but they do more than validation, so that doesn't sounnd
> > right either. Maybe "set" would be the right word ("set_directories",
> > etc.).
>
> Argh... naming things... I used "clean" as e verb, because after calling them
> we can assume the namespace to be "clean", usable. Maybe "handle_*" could be
> better?

"handle" is certainly reasonable.

> > If a function_args_map action raises an exception in main, the exception
> > is returned (not reraised) and the return value of main is passed to
> > sys.exit which expects an integer. That doesn't seem quite right.
>
> sys.exit expects other types too, and in that case it prints the string
> representation of the passed object to stderr and then exits with 1, that's
> what we want.

Well, I guess I learned something new today. :)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'utilities/setuplxc.py'
2--- utilities/setuplxc.py 1970-01-01 00:00:00 +0000
3+++ utilities/setuplxc.py 2012-01-30 09:59:36 +0000
4@@ -0,0 +1,819 @@
5+#!/usr/bin/env python
6+# Copyright 2012 Canonical Ltd. This software is licensed under the
7+# GNU Affero General Public License version 3 (see the file LICENSE).
8+
9+"""Create an LXC test environment for Launchpad testing."""
10+
11+__metaclass__ = type
12+__all__ = [
13+ 'ArgumentParser',
14+ 'cd',
15+ 'create_lxc',
16+ 'file_append',
17+ 'file_prepend',
18+ 'get_container_path',
19+ 'get_user_ids',
20+ 'initialize_host',
21+ 'initialize_lxc',
22+ 'SetupLXCError',
23+ 'ssh',
24+ 'SSHError',
25+ 'stop_lxc',
26+ 'su',
27+ 'user_exists',
28+ 'ValidationError',
29+ ]
30+
31+# To run doctests: python -m doctest -v setuplxc.py
32+
33+from collections import namedtuple, OrderedDict
34+from contextlib import contextmanager
35+from email.Utils import parseaddr
36+import argparse
37+import os
38+import pwd
39+import shutil
40+import subprocess
41+import sys
42+import time
43+
44+
45+DEPENDENCIES_DIR = '~/dependencies'
46+DHCP_FILE = '/etc/dhcp/dhclient.conf'
47+HOST_PACKAGES = ['ssh', 'lxc', 'libvirt-bin', 'bzr', 'language-pack-en']
48+HOSTS_FILE = '/etc/hosts'
49+LP_APACHE_MODULES = 'proxy proxy_http rewrite ssl deflate headers'
50+LP_APACHE_ROOTS = (
51+ '/var/tmp/bazaar.launchpad.dev/static',
52+ '/var/tmp/archive',
53+ '/var/tmp/ppa',
54+ )
55+LP_CHECKOUT = 'devel'
56+LP_DEB_DEPENDENCIES = (
57+ 'bzr launchpad-developer-dependencies apache2 '
58+ 'apache2-mpm-worker libapache2-mod-wsgi')
59+LP_REPOSITORY = 'lp:launchpad'
60+LP_SOURCE_DEPS = (
61+ 'http://bazaar.launchpad.net/~launchpad/lp-source-dependencies/trunk')
62+LXC_CONFIG_TEMPLATE = '/etc/lxc/local.conf'
63+LXC_GATEWAY = '10.0.3.1'
64+LXC_GUEST_OS = 'lucid'
65+LXC_HOSTS_CONTENT = (
66+ ('127.0.0.88',
67+ 'launchpad.dev answers.launchpad.dev archive.launchpad.dev '
68+ 'api.launchpad.dev bazaar-internal.launchpad.dev beta.launchpad.dev '
69+ 'blueprints.launchpad.dev bugs.launchpad.dev code.launchpad.dev '
70+ 'feeds.launchpad.dev id.launchpad.dev keyserver.launchpad.dev '
71+ 'lists.launchpad.dev openid.launchpad.dev '
72+ 'ubuntu-openid.launchpad.dev ppa.launchpad.dev '
73+ 'private-ppa.launchpad.dev testopenid.dev translations.launchpad.dev '
74+ 'xmlrpc-private.launchpad.dev xmlrpc.launchpad.dev'),
75+ ('127.0.0.99', 'bazaar.launchpad.dev'),
76+ )
77+LXC_NAME = 'lptests'
78+LXC_OPTIONS = (
79+ ('lxc.network.type', 'veth'),
80+ ('lxc.network.link', 'lxcbr0'),
81+ ('lxc.network.flags', 'up'),
82+ )
83+LXC_PATH = '/var/lib/lxc/'
84+LXC_REPOS = (
85+ 'deb http://archive.ubuntu.com/ubuntu '
86+ 'lucid main universe multiverse',
87+ 'deb http://archive.ubuntu.com/ubuntu '
88+ 'lucid-updates main universe multiverse',
89+ 'deb http://archive.ubuntu.com/ubuntu '
90+ 'lucid-security main universe multiverse',
91+ 'deb http://ppa.launchpad.net/launchpad/ppa/ubuntu lucid main',
92+ 'deb http://ppa.launchpad.net/bzr/ppa/ubuntu lucid main',
93+ )
94+RESOLV_FILE = '/etc/resolv.conf'
95+
96+
97+Env = namedtuple('Env', 'uid gid home')
98+
99+
100+class SetupLXCError(Exception):
101+ """Base exception for setuplxc."""
102+
103+
104+class SSHError(SetupLXCError):
105+ """Errors occurred during SSH connection."""
106+
107+
108+class ValidationError(SetupLXCError):
109+ """Argparse invalid arguments."""
110+
111+
112+def bzr_whois(user):
113+ """Return fullname and email of bzr `user`.
114+
115+ Return None if the given `user` does not have a bzr user id.
116+ """
117+ with su(user):
118+ try:
119+ whoami = subprocess.check_output(['bzr', 'whoami'])
120+ except (subprocess.CalledProcessError, OSError):
121+ return None
122+ return parseaddr(whoami)
123+
124+
125+@contextmanager
126+def cd(directory):
127+ """A context manager to temporary change current working dir, e.g.::
128+
129+ >>> import os
130+ >>> os.chdir('/tmp')
131+ >>> with cd('/bin'): print os.getcwd()
132+ /bin
133+ >>> os.getcwd()
134+ '/tmp'
135+ """
136+ cwd = os.getcwd()
137+ os.chdir(directory)
138+ yield
139+ os.chdir(cwd)
140+
141+
142+def file_append(filename, line):
143+ """Append given `line`, if not present, at the end of `filename`.
144+
145+ Usage example::
146+
147+ >>> import tempfile
148+ >>> f = tempfile.NamedTemporaryFile('w', delete=False)
149+ >>> f.write('line1\\n')
150+ >>> f.close()
151+ >>> file_append(f.name, 'new line\\n')
152+ >>> open(f.name).read()
153+ 'line1\\nnew line\\n'
154+
155+ Nothing happens if the file already contains the given `line`::
156+
157+ >>> file_append(f.name, 'new line\\n')
158+ >>> open(f.name).read()
159+ 'line1\\nnew line\\n'
160+
161+ A new line is automatically added before the given `line` if it is not
162+ present at the end of current file content::
163+
164+ >>> import tempfile
165+ >>> f = tempfile.NamedTemporaryFile('w', delete=False)
166+ >>> f.write('line1')
167+ >>> f.close()
168+ >>> file_append(f.name, 'new line\\n')
169+ >>> open(f.name).read()
170+ 'line1\\nnew line\\n'
171+ """
172+ with open(filename, 'a+') as f:
173+ content = f.read()
174+ if line not in content:
175+ if content.endswith('\n'):
176+ f.write(line)
177+ else:
178+ f.write('\n' + line)
179+
180+
181+def file_prepend(filename, line):
182+ """Insert given `line`, if not present, at the beginning of `filename`.
183+
184+ Usage example::
185+
186+ >>> import tempfile
187+ >>> f = tempfile.NamedTemporaryFile('w', delete=False)
188+ >>> f.write('line1\\n')
189+ >>> f.close()
190+ >>> file_prepend(f.name, 'line0\\n')
191+ >>> open(f.name).read()
192+ 'line0\\nline1\\n'
193+
194+ If the file starts with the given `line`, nothing happens::
195+
196+ >>> file_prepend(f.name, 'line0\\n')
197+ >>> open(f.name).read()
198+ 'line0\\nline1\\n'
199+
200+ If the file contains the given `line`, but not at the beginning,
201+ the line is moved on top::
202+
203+ >>> file_prepend(f.name, 'line1\\n')
204+ >>> open(f.name).read()
205+ 'line1\\nline0\\n'
206+ """
207+ with open(filename, 'r+') as f:
208+ lines = f.readlines()
209+ if lines[0] != line:
210+ if line in lines:
211+ lines.remove(line)
212+ lines.insert(0, line)
213+ f.seek(0)
214+ f.writelines(lines)
215+
216+
217+def get_container_path(lxcname, path='', base_path=LXC_PATH):
218+ """Return the path of LXC container called `lxcname`.
219+
220+ If a `path` is given, return that path inside the container, e.g.::
221+
222+ >>> get_container_path('mycontainer')
223+ '/var/lib/lxc/mycontainer/rootfs/'
224+ >>> get_container_path('mycontainer', '/etc/apt/')
225+ '/var/lib/lxc/mycontainer/rootfs/etc/apt/'
226+ >>> get_container_path('mycontainer', 'home')
227+ '/var/lib/lxc/mycontainer/rootfs/home'
228+ """
229+ return os.path.join(base_path, lxcname, 'rootfs', path.lstrip('/'))
230+
231+
232+def get_user_ids(user):
233+ """Return the uid and gid of given `user`, e.g.::
234+
235+ >>> get_user_ids('root')
236+ (0, 0)
237+ """
238+ userdata = pwd.getpwnam(user)
239+ return userdata.pw_uid, userdata.pw_gid
240+
241+
242+def ssh(location, user=None, caller=subprocess.call):
243+ """Return a callable that can be used to run ssh shell commands.
244+
245+ The ssh `location` and, optionally, `user` must be given.
246+ If the user is None then the current user is used for the connection.
247+
248+ The callable internally uses the given `caller`::
249+
250+ >>> def caller(cmd):
251+ ... print cmd
252+ >>> sshcall = ssh('example.com', 'myuser', caller=caller)
253+ >>> root_sshcall = ssh('example.com', caller=caller)
254+ >>> sshcall('ls -l') # doctest: +ELLIPSIS
255+ ('ssh', '-t', ..., 'myuser@example.com', '--', 'ls -l')
256+ >>> root_sshcall('ls -l') # doctest: +ELLIPSIS
257+ ('ssh', '-t', ..., 'example.com', '--', 'ls -l')
258+
259+ If the ssh command exits with an error code, an `SSHError` is raised::
260+
261+ >>> ssh('loc', caller=lambda cmd: 1)('ls -l') # doctest: +ELLIPSIS
262+ Traceback (most recent call last):
263+ SSHError: ...
264+ """
265+ if user is not None:
266+ location = '{}@{}'.format(user, location)
267+
268+ def _sshcall(cmd):
269+ sshcmd = (
270+ 'ssh',
271+ '-t',
272+ '-o', 'StrictHostKeyChecking=no',
273+ '-o', 'UserKnownHostsFile=/dev/null',
274+ location,
275+ '--', cmd,
276+ )
277+ if caller(sshcmd):
278+ raise SSHError('Error running command: ' + ' '.join(sshcmd))
279+
280+ return _sshcall
281+
282+
283+@contextmanager
284+def su(user):
285+ """A context manager to temporary run the script as a different user."""
286+ uid, gid = get_user_ids(user)
287+ os.setegid(gid)
288+ os.seteuid(uid)
289+ current_home = os.getenv('HOME')
290+ home = os.path.join(os.path.sep, 'home', user)
291+ os.environ['HOME'] = home
292+ yield Env(uid, gid, home)
293+ os.setegid(os.getgid())
294+ os.seteuid(os.getuid())
295+ os.environ['HOME'] = current_home
296+
297+
298+def user_exists(username):
299+ """Return True if given `username` exists, e.g.::
300+
301+ >>> user_exists('root')
302+ True
303+ >>> user_exists('_this_user_does_not_exist_')
304+ False
305+ """
306+ try:
307+ pwd.getpwnam(username)
308+ except KeyError:
309+ return False
310+ return True
311+
312+
313+class ArgumentParser(argparse.ArgumentParser):
314+ """A customized parser for argparse."""
315+
316+ validators = ()
317+
318+ def get_args_from_namespace(self, namespace):
319+ """Return a list of arguments taking values from `namespace`.
320+
321+ Having a parser defined as usual::
322+
323+ >>> parser = ArgumentParser()
324+ >>> _ = parser.add_argument('--foo')
325+ >>> _ = parser.add_argument('bar')
326+ >>> namespace = parser.parse_args('--foo eggs spam'.split())
327+
328+ It is possible to recreate the argument list taking values from
329+ a different namespace::
330+
331+ >>> namespace.foo = 'changed'
332+ >>> parser.get_args_from_namespace(namespace)
333+ ['--foo', 'changed', 'spam']
334+ """
335+ args = []
336+ for action in self._actions:
337+ dest = action.dest
338+ option_strings = action.option_strings
339+ value = getattr(namespace, dest, None)
340+ if value:
341+ if option_strings:
342+ args.append(option_strings[0])
343+ args.append(value)
344+ return args
345+
346+ def _validate(self, namespace):
347+ for validator in self.validators:
348+ try:
349+ validator(namespace)
350+ except ValidationError as err:
351+ self.error(err.message)
352+
353+ def parse_args(self, *args, **kwargs):
354+ """Override to add further arguments cleaning and validation.
355+
356+ `self.validators` can contain an iterable of objects that are called
357+ once the arguments namespace is fully populated.
358+ This allows cleaning and validating arguments that depend on
359+ each other, or on the current environment.
360+
361+ Each validator is a callable object, takes the current namespace
362+ and can raise ValidationError if the arguments are not valid::
363+
364+ >>> import sys
365+ >>> stderr, sys.stderr = sys.stderr, sys.stdout
366+ >>> def validator(namespace):
367+ ... raise ValidationError('nothing is going on')
368+ >>> parser = ArgumentParser()
369+ >>> parser.validators = [validator]
370+ >>> parser.parse_args([])
371+ Traceback (most recent call last):
372+ SystemExit: 2
373+ >>> sys.stderr = stderr
374+ """
375+ namespace = super(ArgumentParser, self).parse_args(*args, **kwargs)
376+ self._validate(namespace)
377+ return namespace
378+
379+
380+def handle_users(namespace, euid=None):
381+ """Handle user and lpuser arguments.
382+
383+ If lpuser is not provided by namespace, the user name is used::
384+
385+ >>> namespace = argparse.Namespace(user='myuser', lpuser=None)
386+ >>> handle_users(namespace)
387+ >>> namespace.lpuser
388+ 'myuser'
389+
390+ This validator populates namespace with `home_dir` and `run_as_root`
391+ names::
392+
393+ >>> handle_users(namespace, euid=0)
394+ >>> namespace.home_dir
395+ '/home/myuser'
396+ >>> namespace.run_as_root
397+ True
398+
399+ The validation fails if the current user is root and no user is provided::
400+
401+ >>> namespace = argparse.Namespace(user=None)
402+ >>> handle_users(namespace, euid=0) # doctest: +ELLIPSIS
403+ Traceback (most recent call last):
404+ ValidationError: argument user ...
405+ """
406+ if euid is None:
407+ euid = os.geteuid()
408+ if namespace.user is None:
409+ if not euid:
410+ raise ValidationError('argument user can not be omitted if '
411+ 'the script is run as root.')
412+ namespace.user = pwd.getpwuid(euid).pw_name
413+ if namespace.lpuser is None:
414+ namespace.lpuser = namespace.user
415+ namespace.home_dir = os.path.join(os.path.sep, 'home', namespace.user)
416+ namespace.run_as_root = not euid
417+
418+
419+def handle_userdata(namespace, whois=bzr_whois):
420+ """Handle full_name and email arguments.
421+
422+ If they are not provided, this function tries to obtain them using
423+ the given `whois` callable::
424+
425+ >>> namespace = argparse.Namespace(
426+ ... full_name=None, email=None, user='foo')
427+ >>> email = 'email@example.com'
428+ >>> handle_userdata(namespace, lambda user: (user, email))
429+ >>> namespace.full_name == namespace.user
430+ True
431+ >>> namespace.email == email
432+ True
433+
434+ The validation fails if full_name or email are not provided and
435+ they can not be obtained using the `whois` callable::
436+
437+ >>> namespace = argparse.Namespace(
438+ ... full_name=None, email=None, user='foo')
439+ >>> handle_userdata(namespace, lambda user: None) # doctest: +ELLIPSIS
440+ Traceback (most recent call last):
441+ ValidationError: arguments full-name ...
442+
443+ It does not make sense to provide only one argument::
444+
445+ >>> namespace = argparse.Namespace(full_name='Foo Bar', email=None)
446+ >>> handle_userdata(namespace) # doctest: +ELLIPSIS
447+ Traceback (most recent call last):
448+ ValidationError: arguments full-name ...
449+ """
450+ args = (namespace.full_name, namespace.email)
451+ if not all(args):
452+ if any(args):
453+ raise ValidationError(
454+ 'arguments full-name and email: '
455+ 'either none or both must be provided.')
456+ userdata = whois(namespace.user)
457+ if userdata is None:
458+ raise ValidationError(
459+ 'arguments full-name and email are required: '
460+ 'bzr user id not found.')
461+ namespace.full_name, namespace.email = userdata
462+
463+
464+def handle_ssh_keys(namespace):
465+ """Handle private and public ssh keys.
466+
467+ Keys contained in the namespace are escaped::
468+
469+ >>> private = r'PRIVATE\\nKEY'
470+ >>> public = r'PUBLIC\\nKEY'
471+ >>> namespace = argparse.Namespace(
472+ ... private_key=private, public_key=public)
473+ >>> handle_ssh_keys(namespace)
474+ >>> namespace.private_key == private.decode('string-escape')
475+ True
476+ >>> namespace.public_key == public.decode('string-escape')
477+ True
478+
479+ The validation fails if keys are not provided and can not be found
480+ in the current home directory::
481+
482+ >>> namespace = argparse.Namespace(
483+ ... private_key=private, public_key=None,
484+ ... home_dir='/tmp/__does_not_exists__')
485+ >>> handle_ssh_keys(namespace) # doctest: +ELLIPSIS
486+ Traceback (most recent call last):
487+ ValidationError: argument public_key ...
488+ """
489+ for attr, filename in (
490+ ('private_key', 'id_rsa'),
491+ ('public_key', 'id_rsa.pub')):
492+ value = getattr(namespace, attr)
493+ if value:
494+ setattr(namespace, attr, value.decode('string-escape'))
495+ else:
496+ path = os.path.join(namespace.home_dir, '.ssh', filename)
497+ try:
498+ value = open(path).read()
499+ except IOError:
500+ raise ValidationError(
501+ 'argument {} is required if the system user does not '
502+ 'exists with SSH key pair set up.'.format(attr))
503+ setattr(namespace, attr, value)
504+
505+
506+def handle_directories(namespace):
507+ """Handle checkout and dependencies directories.
508+
509+ The ~ construction is automatically expanded::
510+
511+ >>> namespace = argparse.Namespace(
512+ ... directory='~/launchpad', dependencies_dir='~/launchpad/deps',
513+ ... home_dir='/home/foo')
514+ >>> handle_directories(namespace)
515+ >>> namespace.directory
516+ '/home/foo/launchpad'
517+ >>> namespace.dependencies_dir
518+ '/home/foo/launchpad/deps'
519+
520+ The validation fails for directories not residing inside the home::
521+
522+ >>> namespace = argparse.Namespace(
523+ ... directory='/tmp/launchpad',
524+ ... dependencies_dir='~/launchpad/deps',
525+ ... home_dir='/home/foo')
526+ >>> handle_directories(namespace) # doctest: +ELLIPSIS
527+ Traceback (most recent call last):
528+ ValidationError: argument directory ...
529+
530+ The validation fails if the directory contains spaces::
531+
532+ >>> namespace = argparse.Namespace(directory='my directory')
533+ >>> handle_directories(namespace) # doctest: +ELLIPSIS
534+ Traceback (most recent call last):
535+ ValidationError: argument directory ...
536+ """
537+ if ' ' in namespace.directory:
538+ raise ValidationError('argument directory can not contain spaces.')
539+ for attr in ('directory', 'dependencies_dir'):
540+ directory = getattr(
541+ namespace, attr).replace('~', namespace.home_dir)
542+ if not directory.startswith(namespace.home_dir + os.path.sep):
543+ raise ValidationError(
544+ 'argument {} does not reside under the home '
545+ 'directory of the system user.'.format(attr))
546+ setattr(namespace, attr, directory)
547+
548+
549+parser = ArgumentParser(description=__doc__)
550+parser.add_argument(
551+ '-u', '--user',
552+ help='The name of the system user to be created or updated. '
553+ 'The current user is used if this script is not run as root '
554+ 'and this argument is omitted.')
555+parser.add_argument(
556+ '-e', '--email',
557+ help='The email of the user, used for bzr whoami. This argument can '
558+ 'be omitted if a bzr id exists for current user.')
559+parser.add_argument(
560+ '-f', '--full-name',
561+ help='The full name of the user, used for bzr whoami. This argument can '
562+ 'be omitted if a bzr id exists for current user.')
563+parser.add_argument(
564+ '-l', '--lpuser',
565+ help='The name of the Launchpad user that will be used to check out '
566+ 'dependencies. If not provided, the system user name is used.')
567+parser.add_argument(
568+ '-v', '--private-key',
569+ help='The SSH private key for the Launchpad user (without passphrase). '
570+ 'If the system user already exists with SSH key pair set up, '
571+ 'this argument can be omitted.')
572+parser.add_argument(
573+ '-b', '--public-key',
574+ help='The SSH public key for the Launchpad user. '
575+ 'If the system user already exists with SSH key pair set up, '
576+ 'this argument can be omitted.')
577+parser.add_argument(
578+ '-a', '--actions', nargs='+',
579+ choices=('initialize_host', 'create_lxc', 'initialize_lxc', 'stop_lxc'),
580+ help='Only for debugging. Call one or more internal functions.')
581+parser.add_argument(
582+ '-n', '--lxc-name', default=LXC_NAME,
583+ metavar='LXC_NAME (default={})'.format(LXC_NAME),
584+ help='The LXC container name.')
585+parser.add_argument(
586+ '-d', '--dependencies-dir', default=DEPENDENCIES_DIR,
587+ metavar='DEPENDENCIES_DIR (default={})'.format(DEPENDENCIES_DIR),
588+ help='The directory of the Launchpad dependencies to be created. '
589+ 'The directory must reside under the home directory of the '
590+ 'given user (see -u argument).')
591+parser.add_argument(
592+ 'directory',
593+ help='The directory of the Launchpad repository to be created. '
594+ 'The directory must reside under the home directory of the '
595+ 'given user (see -u argument).')
596+parser.validators = (
597+ handle_users,
598+ handle_userdata,
599+ handle_ssh_keys,
600+ handle_directories,
601+ )
602+
603+
604+def initialize_host(
605+ user, fullname, email, lpuser, private_key, public_key,
606+ dependencies_dir, directory):
607+ """Initialize host machine."""
608+ # Install necessary deb packages. This requires Oneiric or later.
609+ subprocess.call(['apt-get', 'update'])
610+ subprocess.call(['apt-get', '-y', 'install'] + HOST_PACKAGES)
611+ # Create the user (if he does not exist).
612+ if not user_exists(user):
613+ subprocess.call(['useradd', '-m', '-s', '/bin/bash', '-U', user])
614+ # Generate root ssh keys if they do not exist.
615+ if not os.path.exists('/root/.ssh/id_rsa.pub'):
616+ subprocess.call([
617+ 'ssh-keygen', '-q', '-t', 'rsa', '-N', '',
618+ '-f', '/root/.ssh/id_rsa'])
619+ with su(user) as env:
620+ # Set up the user's ssh directory. The ssh key must be associated
621+ # with the lpuser's Launchpad account.
622+ ssh_dir = os.path.join(env.home, '.ssh')
623+ if not os.path.exists(ssh_dir):
624+ os.makedirs(ssh_dir)
625+ priv_file = os.path.join(ssh_dir, 'id_rsa')
626+ pub_file = os.path.join(ssh_dir, 'id_rsa.pub')
627+ auth_file = os.path.join(ssh_dir, 'authorized_keys')
628+ known_hosts = os.path.join(ssh_dir, 'known_hosts')
629+ known_host_content = subprocess.check_output([
630+ 'ssh-keyscan', '-t', 'rsa', 'bazaar.launchpad.net'])
631+ for filename, contents, mode in [
632+ (priv_file, private_key, 'w'),
633+ (pub_file, public_key, 'w'),
634+ (auth_file, public_key, 'a'),
635+ (known_hosts, known_host_content, 'a'),
636+ ]:
637+ with open(filename, mode) as f:
638+ f.write('{}\n'.format(contents))
639+ os.chmod(filename, 0644)
640+ os.chmod(priv_file, 0600)
641+ # Set up bzr and Launchpad authentication.
642+ subprocess.call([
643+ 'bzr', 'whoami', '"{} <{}>"'.format(fullname, email)])
644+ subprocess.call(['bzr', 'lp-login', lpuser])
645+ # Set up the repository.
646+ if not os.path.exists(directory):
647+ os.makedirs(directory)
648+ subprocess.call(['bzr', 'init-repo', directory])
649+ checkout_dir = os.path.join(directory, LP_CHECKOUT)
650+ # bzr branch does not work well with seteuid.
651+ subprocess.call([
652+ 'su', '-', user, '-c',
653+ 'bzr branch {} "{}"'.format(LP_REPOSITORY, checkout_dir)])
654+ with su(user) as env:
655+ # Set up source dependencies.
656+ for subdir in ('eggs', 'yui', 'sourcecode'):
657+ os.makedirs(os.path.join(dependencies_dir, subdir))
658+ with cd(dependencies_dir):
659+ subprocess.call([
660+ 'bzr', 'co', '--lightweight',
661+ LP_SOURCE_DEPS, 'download-cache'])
662+
663+
664+def create_lxc(user, lxcname):
665+ """Create the LXC container that will be used for ephemeral instances."""
666+ # Update resolv file in order to get the ability to ssh into the LXC
667+ # container using its name.
668+ file_prepend(RESOLV_FILE, 'nameserver {}\n'.format(LXC_GATEWAY))
669+ file_append(
670+ DHCP_FILE, 'prepend domain-name-servers {};\n'.format(LXC_GATEWAY))
671+ # Container configuration template.
672+ content = ''.join('{}={}\n'.format(*i) for i in LXC_OPTIONS)
673+ with open(LXC_CONFIG_TEMPLATE, 'w') as f:
674+ f.write(content)
675+ # Creating container.
676+ exit_code = subprocess.call([
677+ 'lxc-create',
678+ '-t', 'ubuntu',
679+ '-n', lxcname,
680+ '-f', LXC_CONFIG_TEMPLATE,
681+ '--',
682+ '-r {} -a i386 -b {}'.format(LXC_GUEST_OS, user),
683+ ])
684+ if exit_code:
685+ raise SetupLXCError('Unable to create the LXC container.')
686+ subprocess.call(['lxc-start', '-n', lxcname, '-d'])
687+ # Set up root ssh key.
688+ user_authorized_keys = os.path.join(
689+ os.path.sep, 'home', user, '.ssh/authorized_keys')
690+ with open(user_authorized_keys, 'a') as f:
691+ f.write(open('/root/.ssh/id_rsa.pub').read())
692+ dst = get_container_path(lxcname, '/root/.ssh/')
693+ if not os.path.exists(dst):
694+ os.makedirs(dst)
695+ shutil.copy(user_authorized_keys, dst)
696+ # SSH into the container.
697+ sshcall = ssh(lxcname, user)
698+ trials = 60
699+ while True:
700+ trials -= 1
701+ try:
702+ sshcall('true')
703+ except SSHError:
704+ if not trials:
705+ raise
706+ time.sleep(1)
707+ else:
708+ break
709+
710+
711+def initialize_lxc(user, dependencies_dir, directory, lxcname):
712+ """Set up the Launchpad development environment inside the LXC container.
713+ """
714+ root_sshcall = ssh(lxcname)
715+ sshcall = ssh(lxcname, user)
716+ # APT repository update.
717+ sources = get_container_path(lxcname, '/etc/apt/sources.list')
718+ with open(sources, 'w') as f:
719+ f.write('\n'.join(LXC_REPOS))
720+ # XXX frankban 2012-01-13 - Bug 892892: upgrading mountall in LXC
721+ # containers currently does not work.
722+ root_sshcall("echo 'mountall hold' | dpkg --set-selections")
723+ # Upgrading packages.
724+ root_sshcall(
725+ 'apt-get update && '
726+ 'DEBIAN_FRONTEND=noninteractive '
727+ 'apt-get -y --allow-unauthenticated install language-pack-en')
728+ root_sshcall(
729+ 'DEBIAN_FRONTEND=noninteractive apt-get -y '
730+ '--allow-unauthenticated install {}'.format(LP_DEB_DEPENDENCIES))
731+ # User configuration.
732+ root_sshcall('adduser {} sudo'.format(user))
733+ pygetgid = 'import pwd; print pwd.getpwnam("{}").pw_gid'.format(user)
734+ gid = "`python -c '{}'`".format(pygetgid)
735+ root_sshcall('addgroup --gid {} {}'.format(gid, user))
736+ # Set up Launchpad dependencies.
737+ checkout_dir = os.path.join(directory, LP_CHECKOUT)
738+ sshcall(
739+ 'cd {} && utilities/update-sourcecode "{}/sourcecode"'.format(
740+ checkout_dir, dependencies_dir))
741+ sshcall(
742+ 'cd {} && utilities/link-external-sourcecode "{}"'.format(
743+ checkout_dir, dependencies_dir))
744+ # Create Apache document roots, to avoid warnings.
745+ sshcall(' && '.join('mkdir -p {}'.format(i) for i in LP_APACHE_ROOTS))
746+ # Set up Apache modules.
747+ for module in LP_APACHE_MODULES.split():
748+ root_sshcall('a2enmod {}'.format(module))
749+ # Launchpad database setup.
750+ root_sshcall(
751+ 'cd {} && utilities/launchpad-database-setup {}'.format(
752+ checkout_dir, user))
753+ sshcall('cd {} && make'.format(checkout_dir))
754+ # Set up container hosts file.
755+ lines = ['{}\t{}'.format(ip, names) for ip, names in LXC_HOSTS_CONTENT]
756+ lxc_hosts_file = get_container_path(lxcname, HOSTS_FILE)
757+ file_append(lxc_hosts_file, '\n'.join(lines))
758+ # Make and install launchpad.
759+ root_sshcall('cd {} && make install'.format(checkout_dir))
760+
761+
762+def stop_lxc(lxcname):
763+ """Stop the lxc instance named `lxcname`."""
764+ ssh(lxcname)('poweroff')
765+ timeout = 30
766+ while timeout:
767+ try:
768+ output = subprocess.check_output([
769+ 'lxc-info', '-n', lxcname], stderr=subprocess.STDOUT)
770+ except subprocess.CalledProcessError:
771+ pass
772+ else:
773+ if 'STOPPED' in output:
774+ break
775+ timeout -= 1
776+ time.sleep(1)
777+ else:
778+ subprocess.call(['lxc-stop', '-n', lxcname])
779+
780+
781+def main(
782+ user, fullname, email, lpuser, private_key, public_key, actions,
783+ lxc_name, dependencies_dir, directory):
784+ function_args_map = OrderedDict((
785+ ('initialize_host', (user, fullname, email, lpuser, private_key,
786+ public_key, dependencies_dir, directory)),
787+ ('create_lxc', (user, lxc_name)),
788+ ('initialize_lxc', (user, dependencies_dir, directory, lxc_name)),
789+ ('stop_lxc', (lxc_name,)),
790+ ))
791+ if actions is None:
792+ actions = function_args_map.keys()
793+ scope = globals()
794+ for action in actions:
795+ try:
796+ scope[action](*function_args_map[action])
797+ except SetupLXCError as err:
798+ return err
799+
800+
801+if __name__ == '__main__':
802+ args = parser.parse_args()
803+ if args.run_as_root:
804+ exit_code = main(
805+ args.user,
806+ args.full_name,
807+ args.email,
808+ args.lpuser,
809+ args.private_key,
810+ args.public_key,
811+ args.actions,
812+ args.lxc_name,
813+ args.dependencies_dir,
814+ args.directory,
815+ )
816+ else:
817+ # If the script is run as normal user, restart it as root using
818+ # all the collected arguments. Note that this step requires user
819+ # interaction: running this script as root is still required
820+ # for non-interactive setup of the Launchpad environment.
821+ exit_code = subprocess.call(
822+ ['sudo', sys.argv[0]] + parser.get_args_from_namespace(args))
823+ sys.exit(exit_code)