Merge lp:~julian-edwards/launchpad/twisted-ftp-poppy into lp:launchpad

Proposed by Julian Edwards
Status: Merged
Approved by: Leonard Richardson
Approved revision: no longer in the source branch.
Merged at revision: 12452
Proposed branch: lp:~julian-edwards/launchpad/twisted-ftp-poppy
Merge into: lp:launchpad
Diff against target: 656 lines (+289/-69)
9 files modified
daemons/poppy-sftp.tac (+17/-17)
lib/canonical/config/schema-lazr.conf (+3/-0)
lib/lp/poppy/__init__.py (+17/-1)
lib/lp/poppy/hooks.py (+18/-2)
lib/lp/poppy/tests/test_poppy.py (+59/-45)
lib/lp/poppy/tests/test_twistedsftp.py (+1/-1)
lib/lp/poppy/twistedftp.py (+152/-0)
lib/lp/poppy/twistedsftp.py (+1/-3)
lib/lp/services/twistedsupport/loggingsupport.py (+21/-0)
To merge this branch: bzr merge lp:~julian-edwards/launchpad/twisted-ftp-poppy
Reviewer Review Type Date Requested Status
Jonathan Lange (community) Approve
Leonard Richardson (community) Approve
Review via email: mp+50721@code.launchpad.net

Commit message

[testfix] [r=jml,leonardr][bug=251685,586695] An implementation of the Poppy FTP server using Twisted.

Description of the change

= Summary =
Extend poppy-sftp to also handle ftp.

== Proposed fix ==
Package uploads are currently handled either in Poppy (FTP) or Poppy-sftp
(SFTP). The former is written using Zope's FTP server, the latter using
Twisted.

The Zope FTP server has a number of bugs, so this branch adds FTP support via
the Twisted libraries.

== Pre-implementation notes ==
None, I suck.

== Implementation details ==
Some interesting points:

 * Poppy doesn't really care about authentication, it lets any credentials
upload files, whether it be anonymous or otherwise. Twisted has a special
mode where if anyone logs in as "anonymous" it will prevent any file uploads.
This behaviour is hard-coded in the base FTPShell class. To get around this,
the FTPFactory's "userAnonymous" is modified to a user called
"userthatwillneverhappen" so that the read-only mode is not encountered for
anonymous access.

 * The old port number was passed on the command line, it's now set in config.

 * The tests were easy to fix since they just needed the PoppyTac fixture
swapped in in favour of the old PoppyTestSetup.

 * The old Poppy had a file mode bug where it set group execute on uploaded
files, this is reflected in the test changes for the new ftp service.

 * The poppy server does post-processing of the upload. There's no way to
know when this is finished, so the test just sleeps for a few seconds. See
bug 586695. The old poppy code had some weird hook to print output to stdout
when it finished uploading, we might need to do that here too.

== Tests ==

bin/test -cvv test_poppy

== Demo and Q/A ==

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/poppy/twistedftp.py
  lib/canonical/config/schema-lazr.conf
  lib/lp/poppy/tests/test_poppy.py
  daemons/poppy-sftp.tac

./lib/canonical/config/schema-lazr.conf
     522: Line exceeds 78 characters.
    1034: Line exceeds 78 characters.

To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

The existing test coverage looks good, which helps compensate for my relative ignorance around this part of the code.

So if someone actually logs in as "userthatwillneverhappen", they won't be able to upload any files? I don't think this is a big deal, just trying to understand what's going on. What would happen if you set that to None?

If you made 0102674 a constant you wouldn't have to explain it twice.

review: Approve
Revision history for this message
Jonathan Lange (jml) wrote :
Download full text (5.8 KiB)

On Tue, Feb 22, 2011 at 10:22 AM, Julian Edwards
<email address hidden> wrote:
> Julian Edwards has proposed merging lp:~julian-edwards/launchpad/twisted-ftp-poppy into lp:launchpad.
>
> Requested reviews:
>  Launchpad code reviewers (launchpad-reviewers)
>
> For more details, see:
> https://code.launchpad.net/~julian-edwards/launchpad/twisted-ftp-poppy/+merge/50721
>
> = Summary =
> Extend poppy-sftp to also handle ftp.
>
> == Proposed fix ==
> Package uploads are currently handled either in Poppy (FTP) or Poppy-sftp
> (SFTP).  The former is written using Zope's FTP server, the latter using
> Twisted.
>
> The Zope FTP server has a number of bugs, so this branch adds FTP support via
> the Twisted libraries.
>
> == Pre-implementation notes ==
> None, I suck.
>
> == Implementation details ==
> Some interesting points:
>
>  * Poppy doesn't really care about authentication, it lets any credentials
> upload files, whether it be anonymous or otherwise.  Twisted has a special
> mode where if anyone logs in as "anonymous" it will prevent any file uploads.
> This behaviour is hard-coded in the base FTPShell class.  To get around this,
> the FTPFactory's "userAnonymous" is modified to a user called
> "userthatwillneverhappen" so that the read-only mode is not encountered for
> anonymous access.
>

...
>  * The poppy server does post-processing of the upload.  There's no way to
> know when this is finished, so the test just sleeps for a few seconds.  See
> bug 586695.  The old poppy code had some weird hook to print output to stdout
> when it finished uploading, we might need to do that here too.

> === modified file 'daemons/poppy-sftp.tac'
> --- daemons/poppy-sftp.tac      2011-02-02 01:24:33 +0000
> +++ daemons/poppy-sftp.tac      2011-02-22 10:21:46 +0000
> @@ -7,10 +7,11 @@
>
>  import os
>
> -from twisted.application import service
> +from twisted.application import service, strports
>  from twisted.conch.interfaces import ISession
>  from twisted.conch.ssh import filetransfer
>  from twisted.cred.portal import IRealm, Portal
> +from twisted.protocols import ftp
>  from twisted.protocols.policies import TimeoutFactory
>  from twisted.python import components
>  from twisted.web.xmlrpc import Proxy
> @@ -20,6 +21,10 @@
>  from canonical.config import config
>  from canonical.launchpad.daemons import readyservice
>
> +from lp.poppy.twistedftp import (
> +    FTPRealm,
> +    PoppyAccessCheck,
> +    )
>  from lp.poppy.twistedsftp import SFTPServer
>  from lp.services.sshserver.auth import (
>     LaunchpadAvatar, PublicKeyFromLaunchpadChecker)
> @@ -85,9 +90,41 @@
>  components.registerAdapter(DoNothingSession, LaunchpadAvatar, ISession)
>
>
> -# Construct an Application that has the Poppy SSH server.
> +class FTPServiceFactory(service.Service):

This class should go in lp/poppy, and be imported by the tac file.
That way it can be re-used and unit tested.

>
> === modified file 'lib/canonical/config/schema-lazr.conf'
> --- lib/canonical/config/schema-lazr.conf       2011-02-21 18:54:53 +0000
> +++ lib/canonical/config/schema-lazr.conf       2011-02-22 10:21:46 +0000
> @@ -1654,6 +1654,9 @@
>  # datatype: string
>  port: tcp:5022
>
> +# data...

Read more...

Revision history for this message
Jonathan Lange (jml) wrote :

Here's what I'd do to get rid of the timeouts:
  * Add a line at the end of Hooks.client_done_hook that logs a magic string to the Twisted logger.
  * Have the fixtures watch the poppytacfixture.logger file, as per tachandler _hasDaemonStarted and _waitForDaemonStartup
  * If you are shy about adding things like that to the log, use an env var to guard it

Times in tests lead to tests that are either needlessly slow or that fail intermittently. To me this is a greater evil than a few more lines of code and tainting the largely theoretical purity of non-test code.

Revision history for this message
Julian Edwards (julian-edwards) wrote :

Right, thanks for the review both of you.

I've made a ton of changes, pretty much everything you mentioned, apart from the strports thing since I want to fix the sftp config to have a plain integer, but that needs production config changes in tandem and I don't need the hassle of that with this branch. I'll fix it later.

Let me know how it looks now.

Cheers.

Revision history for this message
Jonathan Lange (jml) wrote :

Looks good. Line 261 of the diff has a typo ("FT Pserve") and using Hooks._targetcount is preferable to using a global.

review: Approve
Revision history for this message
Julian Edwards (julian-edwards) wrote :

On Wednesday 23 February 2011 13:11:56 you wrote:
> Review: Approve
> Looks good. Line 261 of the diff has a typo ("FT Pserve") and using

Thanks, it was a stray paste in vim's command mode wot did it.

> Hooks._targetcount is preferable to using a global.

That makes it effectively non-static again...?

Revision history for this message
Jonathan Lange (jml) wrote :

On Wed, Feb 23, 2011 at 2:22 PM, Julian Edwards
<email address hidden> wrote:
> On Wednesday 23 February 2011 13:11:56 you wrote:
...
>> Hooks._targetcount is preferable to using a global.
>
> That makes it effectively non-static again...?
>

No, it makes it a class variable rather than an instance variable.

Hooks._targetcount is another way of saying self.__class__._targetcount.

jlm

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'daemons/poppy-sftp.tac'
2--- daemons/poppy-sftp.tac 2011-02-02 01:24:33 +0000
3+++ daemons/poppy-sftp.tac 2011-02-24 10:45:01 +0000
4@@ -5,7 +5,7 @@
5 # twistd -noy sftp.tac
6 # or similar. Refer to the twistd(1) man page for details.
7
8-import os
9+import logging
10
11 from twisted.application import service
12 from twisted.conch.interfaces import ISession
13@@ -20,15 +20,16 @@
14 from canonical.config import config
15 from canonical.launchpad.daemons import readyservice
16
17+from lp.poppy import get_poppy_root
18+from lp.poppy.twistedftp import (
19+ FTPServiceFactory,
20+ )
21 from lp.poppy.twistedsftp import SFTPServer
22 from lp.services.sshserver.auth import (
23 LaunchpadAvatar, PublicKeyFromLaunchpadChecker)
24 from lp.services.sshserver.service import SSHService
25 from lp.services.sshserver.session import DoNothingSession
26
27-# XXX: Rename this file to something that doesn't mention poppy. Talk to
28-# bigjools.
29-
30
31 def make_portal():
32 """Create and return a `Portal` for the SSH service.
33@@ -63,31 +64,30 @@
34 return deferred.addCallback(got_user_dict)
35
36
37-def get_poppy_root():
38- """Return the poppy root to use for this server.
39-
40- If the POPPY_ROOT environment variable is set, use that. If not, use
41- config.poppy.fsroot.
42- """
43- poppy_root = os.environ.get('POPPY_ROOT', None)
44- if poppy_root:
45- return poppy_root
46- return config.poppy.fsroot
47-
48-
49 def poppy_sftp_adapter(avatar):
50 return SFTPServer(avatar, get_poppy_root())
51
52
53+# Connect Python logging to Twisted's logging.
54+from lp.services.twistedsupport.loggingsupport import set_up_tacfile_logging
55+set_up_tacfile_logging("poppy-sftp", logging.INFO)
56+
57+
58 components.registerAdapter(
59 poppy_sftp_adapter, LaunchpadAvatar, filetransfer.ISFTPServer)
60
61 components.registerAdapter(DoNothingSession, LaunchpadAvatar, ISession)
62
63
64-# Construct an Application that has the Poppy SSH server.
65+# ftpport defaults to 2121 in schema-lazr.conf
66+ftpservice = FTPServiceFactory.makeFTPService(port=config.poppy.ftp_port)
67+
68+# Construct an Application that has the Poppy SSH server,
69+# and the Poppy FTP server.
70 application = service.Application('poppy-sftp')
71
72+ftpservice.setServiceParent(application)
73+
74 def timeout_decorator(factory):
75 """Add idle timeouts to a factory."""
76 return TimeoutFactory(factory, timeoutPeriod=config.poppy.idle_timeout)
77
78=== modified file 'lib/canonical/config/schema-lazr.conf'
79--- lib/canonical/config/schema-lazr.conf 2011-02-21 18:54:53 +0000
80+++ lib/canonical/config/schema-lazr.conf 2011-02-24 10:45:01 +0000
81@@ -1654,6 +1654,9 @@
82 # datatype: string
83 port: tcp:5022
84
85+# datatype: string
86+ftp_port: 2121
87+
88 # See [error_reports].
89 copy_to_zlog: False
90
91
92=== modified file 'lib/lp/poppy/__init__.py'
93--- lib/lp/poppy/__init__.py 2009-06-25 05:39:50 +0000
94+++ lib/lp/poppy/__init__.py 2011-02-24 10:45:01 +0000
95@@ -1,4 +1,20 @@
96-# Copyright 2009 Canonical Ltd. This software is licensed under the
97+# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
98 # GNU Affero General Public License version 3 (see the file LICENSE).
99
100 # Make this directory into a Python package.
101+
102+import os
103+
104+from canonical.config import config
105+
106+
107+def get_poppy_root():
108+ """Return the poppy root to use for this server.
109+
110+ If the POPPY_ROOT environment variable is set, use that. If not, use
111+ config.poppy.fsroot.
112+ """
113+ poppy_root = os.environ.get('POPPY_ROOT', None)
114+ if poppy_root:
115+ return poppy_root
116+ return config.poppy.fsroot
117
118=== modified file 'lib/lp/poppy/hooks.py'
119--- lib/lp/poppy/hooks.py 2010-08-20 20:31:18 +0000
120+++ lib/lp/poppy/hooks.py 2011-02-24 10:45:01 +0000
121@@ -3,6 +3,12 @@
122
123 __metaclass__ = type
124
125+__all__ = [
126+ 'Hooks',
127+ 'PoppyInterfaceFailure',
128+ ]
129+
130+
131 import logging
132 import os
133 import shutil
134@@ -19,6 +25,8 @@
135 class Hooks:
136
137 clients = {}
138+ LOG_MAGIC = "Post-processing finished"
139+ _targetcount = 0
140
141 def __init__(self, targetpath, logger, allow_user, cmd=None,
142 targetstart=0, perms=None, prefix=''):
143@@ -26,10 +34,15 @@
144 self.logger = logging.getLogger("%s.Hooks" % logger.name)
145 self.cmd = cmd
146 self.allow_user = allow_user
147- self.targetcount = targetstart
148 self.perms = perms
149 self.prefix = prefix
150
151+ @property
152+ def targetcount(self):
153+ """A guaranteed unique integer for ensuring unique upload dirs."""
154+ Hooks._targetcount += 1
155+ return Hooks._targetcount
156+
157 def new_client_hook(self, fsroot, host, port):
158 """Prepare a new client record indexed by fsroot..."""
159 self.clients[fsroot] = {
160@@ -80,7 +93,6 @@
161 pass
162
163 try:
164- self.targetcount += 1
165 timestamp = time.strftime("%Y%m%d-%H%M%S")
166 path = "upload%s-%s-%06d" % (
167 self.prefix, timestamp, self.targetcount)
168@@ -122,6 +134,9 @@
169 self.lock.release(skip_delete=True)
170
171 self.clients.pop(fsroot)
172+ # This is mainly done so that tests know when the
173+ # post-processing hook has finished.
174+ self.logger.info(self.LOG_MAGIC)
175
176 def auth_verify_hook(self, fsroot, user, password):
177 """Verify that the username matches a distribution we care about.
178@@ -146,3 +161,4 @@
179 #except object, e:
180 # print e
181 #return False
182+
183
184=== modified file 'lib/lp/poppy/tests/test_poppy.py'
185--- lib/lp/poppy/tests/test_poppy.py 2010-11-07 12:10:38 +0000
186+++ lib/lp/poppy/tests/test_poppy.py 2011-02-24 10:45:01 +0000
187@@ -1,14 +1,13 @@
188-# Copyright 2009 Canonical Ltd. This software is licensed under the
189+# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
190 # GNU Affero General Public License version 3 (see the file LICENSE).
191
192 """Functional tests for poppy FTP daemon."""
193
194 __metaclass__ = type
195
196-import ftplib
197 import os
198 import shutil
199-import socket
200+import stat
201 import StringIO
202 import tempfile
203 import time
204@@ -33,10 +32,10 @@
205 ZopelessAppServerLayer,
206 ZopelessDatabaseLayer,
207 )
208-from lp.poppy.tests.helpers import PoppyTestSetup
209 from lp.registry.interfaces.ssh import (
210 ISSHKeySet,
211 )
212+from lp.poppy.hooks import Hooks
213 from lp.testing import TestCaseWithFactory
214
215
216@@ -45,48 +44,35 @@
217
218 def __init__(self, root_dir, factory):
219 self.root_dir = root_dir
220- self.port = 3421
221+ self.port = 2121
222
223 def setUp(self):
224 super(FTPServer, self).setUp()
225- self.poppy = PoppyTestSetup(
226- self.root_dir, port=self.port, cmd='echo CLOSED')
227- self.poppy.startPoppy()
228- self.addCleanup(self.poppy.killPoppy)
229+ self.poppytac = self.useFixture(PoppyTac(self.root_dir))
230+
231+ def getAnonTransport(self):
232+ return get_transport(
233+ 'ftp://anonymous:me@example.com@localhost:%s/' % (self.port,))
234
235 def getTransport(self):
236 return get_transport('ftp://ubuntu:@localhost:%s/' % (self.port,))
237
238 def disconnect(self, transport):
239- transport._get_connection().quit()
240-
241- def _getFTPConnection(self):
242- # poppy usually takes sometime to come up, we need to wait, or insist.
243- conn = ftplib.FTP()
244- while True:
245- try:
246- conn.connect("localhost", self.port)
247- except socket.error:
248- if not self.poppy.alive:
249- raise
250- else:
251- break
252- return conn
253+ transport._get_connection().close()
254
255 def waitForStartUp(self):
256 """Wait for the FTP server to start up."""
257- conn = self._getFTPConnection()
258- conn.quit()
259+ pass
260
261- def waitForClose(self):
262+ def waitForClose(self, number=1):
263 """Wait for an FTP connection to close.
264
265- Poppy is configured to echo 'CLOSED' to stdout when a
266- connection closes, so we wait for CLOSED to appear in its
267+ Poppy is configured to echo 'Post-processing finished' to stdout
268+ when a connection closes, so we wait for that to appear in its
269 output as a way to tell that the server has finished with the
270 connection.
271 """
272- self.poppy.verify_output(['CLOSED'])
273+ self.poppytac.waitForPostProcessing(number)
274
275
276 class SFTPServer(Fixture):
277@@ -126,7 +112,7 @@
278 def setUp(self):
279 super(SFTPServer, self).setUp()
280 self.setUpUser('joe')
281- self.useFixture(PoppyTac(self.root_dir))
282+ self.poppytac = self.useFixture(PoppyTac(self.root_dir))
283
284 def disconnect(self, transport):
285 transport._get_connection().close()
286@@ -134,11 +120,8 @@
287 def waitForStartUp(self):
288 pass
289
290- def waitForClose(self):
291- # XXX: Steve Kowalik 2010-05-24 bug=586695 There has to be a
292- # better way to wait for the SFTP server to process our upload
293- # rather than sleeping for 10 seconds.
294- time.sleep(10)
295+ def waitForClose(self, number=1):
296+ self.poppytac.waitForPostProcessing(number)
297
298 def getTransport(self):
299 return get_transport('sftp://joe@localhost:%s/' % (self.port,))
300@@ -184,6 +167,24 @@
301 def pidfile(self):
302 return os.path.join(self.root, 'poppy-sftp.pid')
303
304+ def waitForPostProcessing(self, number=1):
305+ now = time.time()
306+ deadline = now + 20
307+ while now < deadline and not self._hasPostProcessed(number):
308+ time.sleep(0.1)
309+ now = time.time()
310+
311+ if now >= deadline:
312+ raise Exception("Poppy post-processing did not complete")
313+
314+ def _hasPostProcessed(self, number):
315+ if os.path.exists(self.logfile):
316+ with open(self.logfile, "r") as logfile:
317+ occurrences = logfile.read().count(Hooks.LOG_MAGIC)
318+ return occurrences >= number
319+ else:
320+ return False
321+
322
323 class TestPoppy(TestCaseWithFactory):
324 """Test if poppy.py daemon works properly."""
325@@ -204,14 +205,20 @@
326 upload_dir = contents[1]
327 return os.path.join(self.root_dir, upload_dir, path)
328
329- def test_change_directory(self):
330+ def test_change_directory_anonymous(self):
331+ # Check that FTP access with an anonymous user works.
332+ transport = self.server.getAnonTransport()
333+ self.test_change_directory(transport)
334+
335+ def test_change_directory(self, transport=None):
336 """Check automatic creation of directories 'cwd'ed in.
337
338 Also ensure they are created with proper permission (g+rwxs)
339 """
340 self.server.waitForStartUp()
341
342- transport = self.server.getTransport()
343+ if transport is None:
344+ transport = self.server.getTransport()
345 transport.stat('foo/bar') # .stat will implicity chdir for us
346
347 self.server.disconnect(transport)
348@@ -269,7 +276,11 @@
349 wanted_path = self._uploadPath('foo/bar/baz')
350 fs_content = open(os.path.join(wanted_path)).read()
351 self.assertEqual(fs_content, "fake contents")
352- self.assertEqual(os.stat(wanted_path).st_mode, 0102674)
353+ # Expected mode is -rw-rwSr--.
354+ self.assertEqual(
355+ os.stat(wanted_path).st_mode,
356+ stat.S_IROTH | stat.S_ISGID | stat.S_IRGRP | stat.S_IWGRP
357+ | stat.S_IWUSR | stat.S_IRUSR | stat.S_IFREG)
358
359 def test_full_source_upload(self):
360 """Check that the connection will deal with multiple files being
361@@ -298,13 +309,17 @@
362 if transport._user == 'joe':
363 self.assertEqual(dir_name.startswith('upload-sftp-2'), True)
364 elif transport._user == 'ubuntu':
365- self.assertEqual(dir_name.startswith('upload-2'), True)
366+ self.assertEqual(dir_name.startswith('upload-ftp-2'), True)
367 for upload in files:
368 wanted_path = self._uploadPath(
369 "~ppa-user/ppa/ubuntu/%s" % upload)
370 fs_content = open(os.path.join(wanted_path)).read()
371 self.assertEqual(fs_content, upload)
372- self.assertEqual(os.stat(wanted_path).st_mode, 0102674)
373+ # Expected mode is -rw-rwSr--.
374+ self.assertEqual(
375+ os.stat(wanted_path).st_mode,
376+ stat.S_IROTH | stat.S_ISGID | stat.S_IRGRP | stat.S_IWGRP
377+ | stat.S_IWUSR | stat.S_IRUSR | stat.S_IFREG)
378
379 def test_upload_isolation(self):
380 """Check if poppy isolates the uploads properly.
381@@ -319,13 +334,11 @@
382 fake_file = StringIO.StringIO("ONE")
383 conn_one.put_file('test', fake_file, mode=None)
384 self.server.disconnect(conn_one)
385- self.server.waitForClose()
386
387 conn_two = self.server.getTransport()
388 fake_file = StringIO.StringIO("TWO")
389 conn_two.put_file('test', fake_file, mode=None)
390 self.server.disconnect(conn_two)
391- self.server.waitForClose()
392
393 # Perform a pair of sessions with simultaneous connections.
394 conn_three = self.server.getTransport()
395@@ -338,10 +351,11 @@
396 conn_four.put_file('test', fake_file, mode=None)
397
398 self.server.disconnect(conn_three)
399- self.server.waitForClose()
400
401 self.server.disconnect(conn_four)
402- self.server.waitForClose()
403+
404+ # Wait for the 4 uploads to finish.
405+ self.server.waitForClose(4)
406
407 # Build a list of directories representing the 4 sessions.
408 upload_dirs = [leaf for leaf in sorted(os.listdir(self.root_dir))
409@@ -372,4 +386,4 @@
410 # SFTP doesn't have the concept of the server changing directories, since
411 # clients will only send absolute paths, so drop that test.
412 return exclude_tests_by_condition(
413- suite, condition_id_re(r'test_change_directory\(sftp\)$'))
414+ suite, condition_id_re(r'test_change_directory.*\(sftp\)$'))
415
416=== modified file 'lib/lp/poppy/tests/test_twistedsftp.py'
417--- lib/lp/poppy/tests/test_twistedsftp.py 2010-07-01 19:13:11 +0000
418+++ lib/lp/poppy/tests/test_twistedsftp.py 2011-02-24 10:45:01 +0000
419@@ -51,7 +51,7 @@
420 test_file = open(file_name, 'r')
421 self.assertEqual(test_file.read(), "This is a test")
422 test_file.close()
423- self.assertEqual(os.stat(file_name).st_mode, 0100654)
424+ self.assertEqual(os.stat(file_name).st_mode, 0100644)
425 dir_name = os.path.join(self.sftp_server._current_upload, 'bar/foo')
426 os.makedirs(dir_name)
427 upload_file = self.sftp_server.openFile('bar/foo', None, None)
428
429=== added file 'lib/lp/poppy/twistedftp.py'
430--- lib/lp/poppy/twistedftp.py 1970-01-01 00:00:00 +0000
431+++ lib/lp/poppy/twistedftp.py 2011-02-24 10:45:01 +0000
432@@ -0,0 +1,152 @@
433+# Copyright 2011 Canonical Ltd. This software is licensed under the
434+# GNU Affero General Public License version 3 (see the file LICENSE).
435+
436+"""Twisted FTP implementation of the Poppy upload server."""
437+
438+__metaclass__ = type
439+__all__ = [
440+ 'FTPRealm',
441+ 'PoppyAnonymousShell',
442+ ]
443+
444+import logging
445+import os
446+import tempfile
447+
448+from twisted.application import service, strports
449+from twisted.cred import checkers, credentials
450+from twisted.cred.portal import IRealm, Portal
451+from twisted.internet import defer
452+from twisted.protocols import ftp
453+from twisted.python import filepath
454+
455+from zope.interface import implements
456+
457+from canonical.config import config
458+from lp.poppy import get_poppy_root
459+from lp.poppy.filesystem import UploadFileSystem
460+from lp.poppy.hooks import Hooks
461+
462+
463+class PoppyAccessCheck:
464+ """An `ICredentialsChecker` for Poppy FTP sessions."""
465+ implements(checkers.ICredentialsChecker)
466+ credentialInterfaces = (
467+ credentials.IUsernamePassword, credentials.IAnonymous)
468+
469+ def requestAvatarId(self, credentials):
470+ # Poppy allows any credentials. People can use "anonymous" if
471+ # they want but anything goes. Thus, we don't actually *check* the
472+ # credentials, and we return the standard avatarId for 'anonymous'.
473+ return checkers.ANONYMOUS
474+
475+
476+class PoppyAnonymousShell(ftp.FTPShell):
477+ """The 'command' interface for sessions.
478+
479+ Roughly equivalent to the SFTPServer in the sftp side of things.
480+ """
481+
482+ def __init__(self, fsroot):
483+ self._fs_root = fsroot
484+ self.uploadfilesystem = UploadFileSystem(tempfile.mkdtemp())
485+ self._current_upload = self.uploadfilesystem.rootpath
486+ os.chmod(self._current_upload, 0770)
487+ self._log = logging.getLogger("poppy-sftp")
488+ self.hook = Hooks(
489+ self._fs_root, self._log, "ubuntu", perms='g+rws',
490+ prefix='-ftp')
491+ self.hook.new_client_hook(self._current_upload, 0, 0)
492+ self.hook.auth_verify_hook(self._current_upload, None, None)
493+ super(PoppyAnonymousShell, self).__init__(
494+ filepath.FilePath(self._current_upload))
495+
496+ def openForWriting(self, file_segments):
497+ """Write the uploaded file to disk, safely.
498+
499+ :param file_segments: A list containing string items, one for each
500+ path component of the file being uploaded. The file referenced
501+ is relative to the temporary root for this session.
502+
503+ If the file path contains directories, we create them.
504+ """
505+ filename = os.sep.join(file_segments)
506+ self._create_missing_directories(filename)
507+ return super(PoppyAnonymousShell, self).openForWriting(file_segments)
508+
509+ def makeDirectory(self, path):
510+ """Make a directory using the secure `UploadFileSystem`."""
511+ path = os.sep.join(path)
512+ return defer.maybeDeferred(self.uploadfilesystem.mkdir, path)
513+
514+ def access(self, segments):
515+ """Permissive CWD that auto-creates target directories."""
516+ if segments:
517+ path = self._path(segments)
518+ path.makedirs()
519+ return super(PoppyAnonymousShell, self).access(segments)
520+
521+ def logout(self):
522+ """Called when the client disconnects.
523+
524+ We need to post-process the upload.
525+ """
526+ self.hook.client_done_hook(self._current_upload, 0, 0)
527+
528+ def _create_missing_directories(self, filename):
529+ # Same as SFTPServer
530+ new_dir, new_file = os.path.split(
531+ self.uploadfilesystem._sanitize(filename))
532+ if new_dir != '':
533+ if not os.path.exists(
534+ os.path.join(self._current_upload, new_dir)):
535+ self.uploadfilesystem.mkdir(new_dir)
536+
537+ def list(self, path_segments, attrs):
538+ return defer.fail(ftp.CmdNotImplementedError("LIST"))
539+
540+
541+class FTPRealm:
542+ """FTP Realm that lets anyone in."""
543+ implements(IRealm)
544+
545+ def __init__(self, root):
546+ self.root = root
547+
548+ def requestAvatar(self, avatarId, mind, *interfaces):
549+ """Return a Poppy avatar - that is, an "authorisation".
550+
551+ Poppy FTP avatars are totally fake, we don't care about credentials.
552+ See `PoppyAccessCheck` above.
553+ """
554+ for iface in interfaces:
555+ if iface is ftp.IFTPShell:
556+ avatar = PoppyAnonymousShell(self.root)
557+ return ftp.IFTPShell, avatar, getattr(
558+ avatar, 'logout', lambda: None)
559+ raise NotImplementedError(
560+ "Only IFTPShell interface is supported by this realm")
561+
562+
563+class FTPServiceFactory(service.Service):
564+ """A factory that makes an `FTPService`"""
565+
566+ def __init__(self, port):
567+ realm = FTPRealm(get_poppy_root())
568+ portal = Portal(realm)
569+ portal.registerChecker(PoppyAccessCheck())
570+ factory = ftp.FTPFactory(portal)
571+
572+ factory.tld = get_poppy_root()
573+ factory.protocol = ftp.FTP
574+ factory.welcomeMessage = "Launchpad upload server"
575+ factory.timeOut = config.poppy.idle_timeout
576+
577+ self.ftpfactory = factory
578+ self.portno = port
579+
580+ @staticmethod
581+ def makeFTPService(port=2121):
582+ strport = "tcp:%s" % port
583+ factory = FTPServiceFactory(port)
584+ return strports.service(strport, factory.ftpfactory)
585
586=== modified file 'lib/lp/poppy/twistedsftp.py'
587--- lib/lp/poppy/twistedsftp.py 2010-08-20 20:31:18 +0000
588+++ lib/lp/poppy/twistedsftp.py 2011-02-24 10:45:01 +0000
589@@ -22,7 +22,6 @@
590 adapter,
591 provideHandler,
592 )
593-import zope.component.event
594 from zope.interface import implements
595
596 from lp.poppy.filesystem import UploadFileSystem
597@@ -129,7 +128,7 @@
598 def writeChunk(self, offset, data):
599 try:
600 chunk_file = os.open(
601- self.filename, os.O_CREAT | os.O_WRONLY, 0674)
602+ self.filename, os.O_CREAT | os.O_WRONLY, 0664)
603 except OSError, e:
604 if e.errno != errno.EISDIR:
605 raise
606@@ -143,4 +142,3 @@
607
608 def setAttrs(self, attr):
609 pass
610-
611
612=== modified file 'lib/lp/services/twistedsupport/loggingsupport.py'
613--- lib/lp/services/twistedsupport/loggingsupport.py 2010-08-20 20:31:18 +0000
614+++ lib/lp/services/twistedsupport/loggingsupport.py 2011-02-24 10:45:01 +0000
615@@ -12,6 +12,7 @@
616 'log_oops_from_failure',
617 'set_up_logging_for_script',
618 'set_up_oops_reporting',
619+ 'set_up_tacfile_logging',
620 ]
621
622
623@@ -75,6 +76,25 @@
624 return logger_object
625
626
627+def set_up_tacfile_logging(name, level):
628+ """Create a `Logger` object for use in tac files.
629+
630+ This is preferable to use over `set_up_logging_for_script` for .tac
631+ files since there's no options to pass through. The logger object
632+ is connected to Twisted's log and returned.
633+
634+ :param name: The logger instance name.
635+ :param level: The log level to use, eg. logging.INFO or logging.DEBUG
636+ """
637+ logger = logging.getLogger(name)
638+ channel = logging.StreamHandler(log.StdioOnnaStick())
639+ channel.setLevel(level)
640+ channel.setFormatter(logging.Formatter('%(message)s'))
641+ logger.addHandler(channel)
642+ logger.setLevel(level)
643+ return logger
644+
645+
646 def set_up_oops_reporting(configuration, name, mangle_stdout=True):
647 """Set up OOPS reporting by starting the Twisted logger with an observer.
648
649@@ -188,6 +208,7 @@
650 self.request_count += 1
651 self.logger.log(
652 self.level, 'Sending request [%d]: %s%s', request, method, args)
653+
654 def _logResult(result):
655 self.logger.log(
656 self.level, 'Reply to request [%d]: %s', request, result)