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