chiark / gitweb /
Merge branch 'current-version-links' into 'master'
[fdroidserver.git] / fdroidserver / server.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # server.py - part of the FDroid server tools
5 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import sys
21 import glob
22 import hashlib
23 import os
24 import paramiko
25 import pwd
26 import subprocess
27 from optparse import OptionParser
28 import logging
29 import common
30
31 config = None
32 options = None
33
34
35 def update_awsbucket(repo_section):
36     '''
37     Upload the contents of the directory `repo_section` (including
38     subdirectories) to the AWS S3 "bucket". The contents of that subdir of the
39     bucket will first be deleted.
40
41     Requires AWS credentials set in config.py: awsaccesskeyid, awssecretkey
42     '''
43
44     logging.debug('Syncing "' + repo_section + '" to Amazon S3 bucket "'
45                   + config['awsbucket'] + '"')
46
47     import libcloud.security
48     libcloud.security.VERIFY_SSL_CERT = True
49     from libcloud.storage.types import Provider, ContainerDoesNotExistError
50     from libcloud.storage.providers import get_driver
51
52     if not config.get('awsaccesskeyid') or not config.get('awssecretkey'):
53         logging.error('To use awsbucket, you must set awssecretkey and awsaccesskeyid in config.py!')
54         sys.exit(1)
55     awsbucket = config['awsbucket']
56
57     cls = get_driver(Provider.S3)
58     driver = cls(config['awsaccesskeyid'], config['awssecretkey'])
59     try:
60         container = driver.get_container(container_name=awsbucket)
61     except ContainerDoesNotExistError:
62         container = driver.create_container(container_name=awsbucket)
63         logging.info('Created new container "' + container.name + '"')
64
65     upload_dir = 'fdroid/' + repo_section
66     objs = dict()
67     for obj in container.list_objects():
68         if obj.name.startswith(upload_dir + '/'):
69             objs[obj.name] = obj
70
71     for root, _, files in os.walk(os.path.join(os.getcwd(), repo_section)):
72         for name in files:
73             upload = False
74             file_to_upload = os.path.join(root, name)
75             object_name = 'fdroid/' + os.path.relpath(file_to_upload, os.getcwd())
76             if object_name not in objs:
77                 upload = True
78             else:
79                 obj = objs.pop(object_name)
80                 if obj.size != os.path.getsize(file_to_upload):
81                     upload = True
82                 else:
83                     # if the sizes match, then compare by MD5
84                     md5 = hashlib.md5()
85                     with open(file_to_upload, 'rb') as f:
86                         while True:
87                             data = f.read(8192)
88                             if not data:
89                                 break
90                             md5.update(data)
91                     if obj.hash != md5.hexdigest():
92                         s3url = 's3://' + awsbucket + '/' + obj.name
93                         logging.info(' deleting ' + s3url)
94                         if not driver.delete_object(obj):
95                             logging.warn('Could not delete ' + s3url)
96                         upload = True
97
98             if upload:
99                 logging.debug(' uploading "' + file_to_upload + '"...')
100                 extra = {'acl': 'public-read'}
101                 if file_to_upload.endswith('.sig'):
102                     extra['content_type'] = 'application/pgp-signature'
103                 elif file_to_upload.endswith('.asc'):
104                     extra['content_type'] = 'application/pgp-signature'
105                 logging.info(' uploading ' + os.path.relpath(file_to_upload)
106                              + ' to s3://' + awsbucket + '/' + object_name)
107                 obj = driver.upload_object(file_path=file_to_upload,
108                                            container=container,
109                                            object_name=object_name,
110                                            verify_hash=False,
111                                            extra=extra)
112     # delete the remnants in the bucket, they do not exist locally
113     while objs:
114         object_name, obj = objs.popitem()
115         s3url = 's3://' + awsbucket + '/' + object_name
116         if object_name.startswith(upload_dir):
117             logging.warn(' deleting ' + s3url)
118             driver.delete_object(obj)
119         else:
120             logging.info(' skipping ' + s3url)
121
122
123 def update_serverwebroot(serverwebroot, repo_section):
124     # use a checksum comparison for accurate comparisons on different
125     # filesystems, for example, FAT has a low resolution timestamp
126     rsyncargs = ['rsync', '--archive', '--delete', '--checksum']
127     if options.verbose:
128         rsyncargs += ['--verbose']
129     if options.quiet:
130         rsyncargs += ['--quiet']
131     if options.identity_file is not None:
132         rsyncargs += ['-e', 'ssh -i ' + options.identity_file]
133     if 'identity_file' in config:
134         rsyncargs += ['-e', 'ssh -i ' + config['identity_file']]
135     indexxml = os.path.join(repo_section, 'index.xml')
136     indexjar = os.path.join(repo_section, 'index.jar')
137     # upload the first time without the index so that the repo stays working
138     # while this update is running.  Then once it is complete, rerun the
139     # command again to upload the index.  Always using the same target with
140     # rsync allows for very strict settings on the receiving server, you can
141     # literally specify the one rsync command that is allowed to run in
142     # ~/.ssh/authorized_keys.  (serverwebroot is guaranteed to have a trailing
143     # slash in common.py)
144     if subprocess.call(rsyncargs +
145                        ['--exclude', indexxml, '--exclude', indexjar,
146                         repo_section, serverwebroot]) != 0:
147         sys.exit(1)
148     if subprocess.call(rsyncargs + [repo_section, serverwebroot]) != 0:
149         sys.exit(1)
150     # upload "current version" symlinks if requested
151     if config['make_current_version_link'] and repo_section == 'repo':
152         links_to_upload = []
153         for f in glob.glob('*.apk') \
154                 + glob.glob('*.apk.asc') + glob.glob('*.apk.sig'):
155             if os.path.islink(f):
156                 links_to_upload.append(f)
157         if len(links_to_upload) > 0:
158             if subprocess.call(rsyncargs + links_to_upload + [serverwebroot]) != 0:
159                 sys.exit(1)
160
161
162 def _local_sync(fromdir, todir):
163     rsyncargs = ['rsync', '--recursive', '--links', '--times', '--perms',
164                  '--one-file-system', '--delete', '--chmod=Da+rx,Fa-x,a+r,u+w']
165     # use stricter rsync checking on all files since people using offline mode
166     # are already prioritizing security above ease and speed
167     rsyncargs += ['--checksum']
168     if options.verbose:
169         rsyncargs += ['--verbose']
170     if options.quiet:
171         rsyncargs += ['--quiet']
172     logging.debug(' '.join(rsyncargs + [fromdir, todir]))
173     if subprocess.call(rsyncargs + [fromdir, todir]) != 0:
174         sys.exit(1)
175
176
177 def sync_from_localcopy(repo_section, local_copy_dir):
178     logging.info('Syncing from local_copy_dir to this repo.')
179     # trailing slashes have a meaning in rsync which is not needed here, so
180     # make sure both paths have exactly one trailing slash
181     _local_sync(os.path.join(local_copy_dir, repo_section).rstrip('/') + '/',
182                 repo_section.rstrip('/') + '/')
183
184
185 def update_localcopy(repo_section, local_copy_dir):
186     # local_copy_dir is guaranteed to have a trailing slash in main() below
187     _local_sync(repo_section, local_copy_dir)
188
189
190 def main():
191     global config, options
192
193     # Parse command line...
194     parser = OptionParser()
195     parser.add_option("-i", "--identity-file", default=None,
196                       help="Specify an identity file to provide to SSH for rsyncing")
197     parser.add_option("--local-copy-dir", default=None,
198                       help="Specify a local folder to sync the repo to")
199     parser.add_option("--sync-from-local-copy-dir", action="store_true", default=False,
200                       help="Before uploading to servers, sync from local copy dir")
201     parser.add_option("-v", "--verbose", action="store_true", default=False,
202                       help="Spew out even more information than normal")
203     parser.add_option("-q", "--quiet", action="store_true", default=False,
204                       help="Restrict output to warnings and errors")
205     (options, args) = parser.parse_args()
206
207     config = common.read_config(options)
208
209     if len(args) != 1:
210         logging.critical("Specify a single command")
211         sys.exit(1)
212
213     if args[0] != 'init' and args[0] != 'update':
214         logging.critical("The only commands currently supported are 'init' and 'update'")
215         sys.exit(1)
216
217     if config.get('nonstandardwebroot') is True:
218         standardwebroot = False
219     else:
220         standardwebroot = True
221
222     for serverwebroot in config.get('serverwebroot', []):
223         host, fdroiddir = serverwebroot.rstrip('/').split(':')
224         repobase = os.path.basename(fdroiddir)
225         if standardwebroot and repobase != 'fdroid':
226             logging.error('serverwebroot path does not end with "fdroid", '
227                           + 'perhaps you meant one of these:\n\t'
228                           + serverwebroot.rstrip('/') + '/fdroid\n\t'
229                           + serverwebroot.rstrip('/').rstrip(repobase) + 'fdroid')
230             sys.exit(1)
231
232     if options.local_copy_dir is not None:
233         local_copy_dir = options.local_copy_dir
234     elif config.get('local_copy_dir'):
235         local_copy_dir = config['local_copy_dir']
236     else:
237         local_copy_dir = None
238     if local_copy_dir is not None:
239         fdroiddir = local_copy_dir.rstrip('/')
240         if os.path.exists(fdroiddir) and not os.path.isdir(fdroiddir):
241             logging.error('local_copy_dir must be directory, not a file!')
242             sys.exit(1)
243         if not os.path.exists(os.path.dirname(fdroiddir)):
244             logging.error('The root dir for local_copy_dir "'
245                           + os.path.dirname(fdroiddir)
246                           + '" does not exist!')
247             sys.exit(1)
248         if not os.path.isabs(fdroiddir):
249             logging.error('local_copy_dir must be an absolute path!')
250             sys.exit(1)
251         repobase = os.path.basename(fdroiddir)
252         if standardwebroot and repobase != 'fdroid':
253             logging.error('local_copy_dir does not end with "fdroid", '
254                           + 'perhaps you meant: ' + fdroiddir + '/fdroid')
255             sys.exit(1)
256         if local_copy_dir[-1] != '/':
257             local_copy_dir += '/'
258         local_copy_dir = local_copy_dir.replace('//', '/')
259         if not os.path.exists(fdroiddir):
260             os.mkdir(fdroiddir)
261
262     if not config.get('awsbucket') \
263             and not config.get('serverwebroot') \
264             and local_copy_dir is None:
265         logging.warn('No serverwebroot, local_copy_dir, or awsbucket set!'
266                      + 'Edit your config.py to set at least one.')
267         sys.exit(1)
268
269     repo_sections = ['repo']
270     if config['archive_older'] != 0:
271         repo_sections.append('archive')
272         if not os.path.exists('archive'):
273             os.mkdir('archive')
274
275     if args[0] == 'init':
276         ssh = paramiko.SSHClient()
277         ssh.load_system_host_keys()
278         for serverwebroot in config.get('serverwebroot', []):
279             sshstr, remotepath = serverwebroot.rstrip('/').split(':')
280             if sshstr.find('@') >= 0:
281                 username, hostname = sshstr.split('@')
282             else:
283                 username = pwd.getpwuid(os.getuid())[0]  # get effective uid
284                 hostname = sshstr
285             ssh.connect(hostname, username=username)
286             sftp = ssh.open_sftp()
287             if os.path.basename(remotepath) \
288                     not in sftp.listdir(os.path.dirname(remotepath)):
289                 sftp.mkdir(remotepath, mode=0755)
290             for repo_section in repo_sections:
291                 repo_path = os.path.join(remotepath, repo_section)
292                 if os.path.basename(repo_path) \
293                         not in sftp.listdir(remotepath):
294                     sftp.mkdir(repo_path, mode=0755)
295             sftp.close()
296             ssh.close()
297     elif args[0] == 'update':
298         for repo_section in repo_sections:
299             if local_copy_dir is not None:
300                 if config['sync_from_local_copy_dir'] and os.path.exists(repo_section):
301                     sync_from_localcopy(repo_section, local_copy_dir)
302                 else:
303                     update_localcopy(repo_section, local_copy_dir)
304             for serverwebroot in config.get('serverwebroot', []):
305                 update_serverwebroot(serverwebroot, repo_section)
306             if config.get('awsbucket'):
307                 update_awsbucket(repo_section)
308
309     sys.exit(0)
310
311 if __name__ == "__main__":
312     main()