chiark / gitweb /
Merge branch 'per-app-repos' 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-15, 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                 with open(file_to_upload, 'rb') as iterator:
108                     obj = driver.upload_object_via_stream(iterator=iterator,
109                                                           container=container,
110                                                           object_name=object_name,
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-after', '--safe-links']
127     if not options.no_checksum:
128         rsyncargs.append('--checksum')
129     if options.verbose:
130         rsyncargs += ['--verbose']
131     if options.quiet:
132         rsyncargs += ['--quiet']
133     if options.identity_file is not None:
134         rsyncargs += ['-e', 'ssh -i ' + options.identity_file]
135     if 'identity_file' in config:
136         rsyncargs += ['-e', 'ssh -i ' + config['identity_file']]
137     indexxml = os.path.join(repo_section, 'index.xml')
138     indexjar = os.path.join(repo_section, 'index.jar')
139     # Upload the first time without the index files and delay the deletion as
140     # much as possible, that keeps the repo functional while this update is
141     # running.  Then once it is complete, rerun the command again to upload
142     # the index files.  Always using the same target with rsync allows for
143     # very strict settings on the receiving server, you can literally specify
144     # the one rsync command that is allowed to run in ~/.ssh/authorized_keys.
145     # (serverwebroot is guaranteed to have a trailing slash in common.py)
146     logging.info('rsyncing ' + repo_section + ' to ' + serverwebroot)
147     if subprocess.call(rsyncargs +
148                        ['--exclude', indexxml, '--exclude', indexjar,
149                         repo_section, serverwebroot]) != 0:
150         sys.exit(1)
151     if subprocess.call(rsyncargs + [repo_section, serverwebroot]) != 0:
152         sys.exit(1)
153     # upload "current version" symlinks if requested
154     if config['make_current_version_link'] and repo_section == 'repo':
155         links_to_upload = []
156         for f in glob.glob('*.apk') \
157                 + glob.glob('*.apk.asc') + glob.glob('*.apk.sig'):
158             if os.path.islink(f):
159                 links_to_upload.append(f)
160         if len(links_to_upload) > 0:
161             if subprocess.call(rsyncargs + links_to_upload + [serverwebroot]) != 0:
162                 sys.exit(1)
163
164
165 def _local_sync(fromdir, todir):
166     rsyncargs = ['rsync', '--recursive', '--safe-links', '--times', '--perms',
167                  '--one-file-system', '--delete', '--chmod=Da+rx,Fa-x,a+r,u+w']
168     # use stricter rsync checking on all files since people using offline mode
169     # are already prioritizing security above ease and speed
170     if not options.no_checksum:
171         rsyncargs.append('--checksum')
172     if options.verbose:
173         rsyncargs += ['--verbose']
174     if options.quiet:
175         rsyncargs += ['--quiet']
176     logging.debug(' '.join(rsyncargs + [fromdir, todir]))
177     if subprocess.call(rsyncargs + [fromdir, todir]) != 0:
178         sys.exit(1)
179
180
181 def sync_from_localcopy(repo_section, local_copy_dir):
182     logging.info('Syncing from local_copy_dir to this repo.')
183     # trailing slashes have a meaning in rsync which is not needed here, so
184     # make sure both paths have exactly one trailing slash
185     _local_sync(os.path.join(local_copy_dir, repo_section).rstrip('/') + '/',
186                 repo_section.rstrip('/') + '/')
187
188
189 def update_localcopy(repo_section, local_copy_dir):
190     # local_copy_dir is guaranteed to have a trailing slash in main() below
191     _local_sync(repo_section, local_copy_dir)
192
193
194 def main():
195     global config, options
196
197     # Parse command line...
198     parser = OptionParser()
199     parser.add_option("-i", "--identity-file", default=None,
200                       help="Specify an identity file to provide to SSH for rsyncing")
201     parser.add_option("--local-copy-dir", default=None,
202                       help="Specify a local folder to sync the repo to")
203     parser.add_option("--sync-from-local-copy-dir", action="store_true", default=False,
204                       help="Before uploading to servers, sync from local copy dir")
205     parser.add_option("-v", "--verbose", action="store_true", default=False,
206                       help="Spew out even more information than normal")
207     parser.add_option("-q", "--quiet", action="store_true", default=False,
208                       help="Restrict output to warnings and errors")
209     parser.add_option("--no-checksum", action="store_true", default=False,
210                       help="Don't use rsync checksums")
211     (options, args) = parser.parse_args()
212
213     config = common.read_config(options)
214
215     if len(args) != 1:
216         logging.critical("Specify a single command")
217         sys.exit(1)
218
219     if args[0] != 'init' and args[0] != 'update':
220         logging.critical("The only commands currently supported are 'init' and 'update'")
221         sys.exit(1)
222
223     if config.get('nonstandardwebroot') is True:
224         standardwebroot = False
225     else:
226         standardwebroot = True
227
228     for serverwebroot in config.get('serverwebroot', []):
229         # this supports both an ssh host:path and just a path
230         s = serverwebroot.rstrip('/').split(':')
231         if len(s) == 1:
232             fdroiddir = s[0]
233         elif len(s) == 2:
234             host, fdroiddir = s
235         else:
236             logging.error('Malformed serverwebroot line: ' + serverwebroot)
237             sys.exit(1)
238         repobase = os.path.basename(fdroiddir)
239         if standardwebroot and repobase != 'fdroid':
240             logging.error('serverwebroot path does not end with "fdroid", '
241                           + 'perhaps you meant one of these:\n\t'
242                           + serverwebroot.rstrip('/') + '/fdroid\n\t'
243                           + serverwebroot.rstrip('/').rstrip(repobase) + 'fdroid')
244             sys.exit(1)
245
246     if options.local_copy_dir is not None:
247         local_copy_dir = options.local_copy_dir
248     elif config.get('local_copy_dir'):
249         local_copy_dir = config['local_copy_dir']
250     else:
251         local_copy_dir = None
252     if local_copy_dir is not None:
253         fdroiddir = local_copy_dir.rstrip('/')
254         if os.path.exists(fdroiddir) and not os.path.isdir(fdroiddir):
255             logging.error('local_copy_dir must be directory, not a file!')
256             sys.exit(1)
257         if not os.path.exists(os.path.dirname(fdroiddir)):
258             logging.error('The root dir for local_copy_dir "'
259                           + os.path.dirname(fdroiddir)
260                           + '" does not exist!')
261             sys.exit(1)
262         if not os.path.isabs(fdroiddir):
263             logging.error('local_copy_dir must be an absolute path!')
264             sys.exit(1)
265         repobase = os.path.basename(fdroiddir)
266         if standardwebroot and repobase != 'fdroid':
267             logging.error('local_copy_dir does not end with "fdroid", '
268                           + 'perhaps you meant: ' + fdroiddir + '/fdroid')
269             sys.exit(1)
270         if local_copy_dir[-1] != '/':
271             local_copy_dir += '/'
272         local_copy_dir = local_copy_dir.replace('//', '/')
273         if not os.path.exists(fdroiddir):
274             os.mkdir(fdroiddir)
275
276     if not config.get('awsbucket') \
277             and not config.get('serverwebroot') \
278             and local_copy_dir is None:
279         logging.warn('No serverwebroot, local_copy_dir, or awsbucket set!'
280                      + 'Edit your config.py to set at least one.')
281         sys.exit(1)
282
283     repo_sections = ['repo']
284     if config['archive_older'] != 0:
285         repo_sections.append('archive')
286         if not os.path.exists('archive'):
287             os.mkdir('archive')
288     if config['per_app_repos']:
289         repo_sections += common.get_per_app_repos()
290
291     if args[0] == 'init':
292         ssh = paramiko.SSHClient()
293         ssh.load_system_host_keys()
294         for serverwebroot in config.get('serverwebroot', []):
295             sshstr, remotepath = serverwebroot.rstrip('/').split(':')
296             if sshstr.find('@') >= 0:
297                 username, hostname = sshstr.split('@')
298             else:
299                 username = pwd.getpwuid(os.getuid())[0]  # get effective uid
300                 hostname = sshstr
301             ssh.connect(hostname, username=username)
302             sftp = ssh.open_sftp()
303             if os.path.basename(remotepath) \
304                     not in sftp.listdir(os.path.dirname(remotepath)):
305                 sftp.mkdir(remotepath, mode=0755)
306             for repo_section in repo_sections:
307                 repo_path = os.path.join(remotepath, repo_section)
308                 if os.path.basename(repo_path) \
309                         not in sftp.listdir(remotepath):
310                     sftp.mkdir(repo_path, mode=0755)
311             sftp.close()
312             ssh.close()
313     elif args[0] == 'update':
314         for repo_section in repo_sections:
315             if local_copy_dir is not None:
316                 if config['sync_from_local_copy_dir'] and os.path.exists(repo_section):
317                     sync_from_localcopy(repo_section, local_copy_dir)
318                 else:
319                     update_localcopy(repo_section, local_copy_dir)
320             for serverwebroot in config.get('serverwebroot', []):
321                 update_serverwebroot(serverwebroot, repo_section)
322             if config.get('awsbucket'):
323                 update_awsbucket(repo_section)
324
325     sys.exit(0)
326
327 if __name__ == "__main__":
328     main()