chiark / gitweb /
Merge branch 'log_git' into 'master'
[fdroidserver.git] / fdroidserver / nightly.py
1 #!/usr/bin/env python3
2 #
3 # nightly.py - part of the FDroid server tools
4 # Copyright (C) 2017 Hans-Christoph Steiner <hans@eds.org>
5 #
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 # GNU Affero General Public License for more details.
15 #
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
18
19 import base64
20 import datetime
21 import git
22 import hashlib
23 import logging
24 import os
25 import paramiko
26 import platform
27 import shutil
28 import subprocess
29 import sys
30 import tempfile
31 from urllib.parse import urlparse
32 from argparse import ArgumentParser
33
34 from . import _
35 from . import common
36
37
38 # hard coded defaults for Android ~/.android/debug.keystore files
39 # https://developers.google.com/android/guides/client-auth
40 KEYSTORE_FILE = os.path.join(os.getenv('HOME'), '.android', 'debug.keystore')
41 PASSWORD = 'android'
42 KEY_ALIAS = 'androiddebugkey'
43 DISTINGUISHED_NAME = 'CN=Android Debug,O=Android,C=US'
44
45 # standard suffix for naming fdroid git repos
46 NIGHTLY = '-nightly'
47
48
49 def _ssh_key_from_debug_keystore():
50     tmp_dir = tempfile.mkdtemp(prefix='.')
51     privkey = os.path.join(tmp_dir, '.privkey')
52     key_pem = os.path.join(tmp_dir, '.key.pem')
53     p12 = os.path.join(tmp_dir, '.keystore.p12')
54     subprocess.check_call([common.config['keytool'], '-importkeystore',
55                            '-srckeystore', KEYSTORE_FILE, '-srcalias', KEY_ALIAS,
56                            '-srcstorepass', PASSWORD, '-srckeypass', PASSWORD,
57                            '-destkeystore', p12, '-destalias', KEY_ALIAS,
58                            '-deststorepass', PASSWORD, '-destkeypass', PASSWORD,
59                            '-deststoretype', 'PKCS12'])
60     subprocess.check_call(['openssl', 'pkcs12', '-in', p12, '-out', key_pem,
61                            '-passin', 'pass:' + PASSWORD, '-passout', 'pass:' + PASSWORD])
62     subprocess.check_call(['openssl', 'rsa', '-in', key_pem, '-out', privkey,
63                            '-passin', 'pass:' + PASSWORD])
64     os.remove(key_pem)
65     os.remove(p12)
66     os.chmod(privkey, 0o600)  # os.umask() should cover this, but just in case
67
68     rsakey = paramiko.RSAKey.from_private_key_file(privkey)
69     fingerprint = base64.b64encode(hashlib.sha256(rsakey.asbytes()).digest()).decode('ascii').rstrip('=')
70     ssh_private_key_file = os.path.join(tmp_dir, 'debug_keystore_' + fingerprint + '_id_rsa')
71     os.rename(privkey, ssh_private_key_file)
72
73     pub = rsakey.get_name() + ' ' + rsakey.get_base64() + ' ' + ssh_private_key_file
74     with open(ssh_private_key_file + '.pub', 'w') as fp:
75         fp.write(pub)
76
77     logging.info(_('\nSSH Public Key to be used as Deploy Key:') + '\n' + pub)
78
79     return ssh_private_key_file
80
81
82 def main():
83
84     parser = ArgumentParser(usage="%(prog)s")
85     common.setup_global_opts(parser)
86     parser.add_argument("--show-secret-var", action="store_true", default=False,
87                         help=_("Print the secret variable to the terminal for easy copy/paste"))
88     parser.add_argument("--file", default='app/build/outputs/apk/*.apk',
89                         help=_('The the file to be included in the repo (path or glob)'))
90     parser.add_argument("--no-checksum", action="store_true", default=False,
91                         help=_("Don't use rsync checksums"))
92     # TODO add --with-btlog
93     options = parser.parse_args()
94     common.read_config(None)
95
96     # force a tighter umask since this writes private key material
97     umask = os.umask(0o077)
98
99     if 'CI' in os.environ:
100         v = os.getenv('DEBUG_KEYSTORE')
101         debug_keystore = None
102         if v:
103             debug_keystore = base64.b64decode(v)
104         if not debug_keystore:
105             logging.error(_('DEBUG_KEYSTORE is not set or the value is incomplete'))
106             sys.exit(1)
107         os.makedirs(os.path.dirname(KEYSTORE_FILE), exist_ok=True)
108         if os.path.exists(KEYSTORE_FILE):
109             logging.warning(_('overwriting existing {path}').format(path=KEYSTORE_FILE))
110         with open(KEYSTORE_FILE, 'wb') as fp:
111             fp.write(debug_keystore)
112
113         repo_basedir = os.path.join(os.getcwd(), 'fdroid')
114         repodir = os.path.join(repo_basedir, 'repo')
115         cibase = os.getcwd()
116         os.makedirs(repodir, exist_ok=True)
117
118         if 'CI_PROJECT_PATH' in os.environ and 'CI_PROJECT_URL' in os.environ:
119             # we are in GitLab CI
120             repo_git_base = os.getenv('CI_PROJECT_PATH') + NIGHTLY
121             clone_url = os.getenv('CI_PROJECT_URL') + NIGHTLY
122             repo_base = clone_url + '/raw/master/fdroid'
123             servergitmirror = 'git@' + urlparse(clone_url).netloc + ':' + repo_git_base
124             deploy_key_url = clone_url + '/settings/repository'
125             git_user_name = os.getenv('GITLAB_USER_NAME')
126             git_user_email = os.getenv('GITLAB_USER_EMAIL')
127         elif 'TRAVIS_REPO_SLUG' in os.environ:
128             # we are in Travis CI
129             repo_git_base = os.getenv('TRAVIS_REPO_SLUG') + NIGHTLY
130             clone_url = 'https://github.com/' + repo_git_base
131             _branch = os.getenv('TRAVIS_BRANCH')
132             repo_base = 'https://raw.githubusercontent.com/' + repo_git_base + '/' + _branch + '/fdroid'
133             servergitmirror = 'git@github.com:' + repo_git_base
134             deploy_key_url = ('https://github.com/' + repo_git_base + '/settings/keys'
135                               + '\nhttps://developer.github.com/v3/guides/managing-deploy-keys/#deploy-keys')
136             git_user_name = repo_git_base
137             git_user_email = os.getenv('USER') + '@' + platform.node()
138         elif 'CIRCLE_REPOSITORY_URL' in os.environ \
139              and 'CIRCLE_PROJECT_USERNAME' in os.environ \
140              and 'CIRCLE_PROJECT_REPONAME' in os.environ:
141             # we are in Circle CI
142             repo_git_base = (os.getenv('CIRCLE_PROJECT_USERNAME')
143                              + '/' + os.getenv('CIRCLE_PROJECT_REPONAME') + NIGHTLY)
144             clone_url = os.getenv('CIRCLE_REPOSITORY_URL') + NIGHTLY
145             repo_base = clone_url + '/raw/master/fdroid'
146             servergitmirror = 'git@' + urlparse(clone_url).netloc + ':' + repo_git_base
147             deploy_key_url = ('https://github.com/' + repo_git_base + '/settings/keys'
148                               + '\nhttps://developer.github.com/v3/guides/managing-deploy-keys/#deploy-keys')
149             git_user_name = os.getenv('CIRCLE_USERNAME')
150             git_user_email = git_user_name + '@' + platform.node()
151         else:
152             print(_('ERROR: unsupported CI type, patches welcome!'))
153             sys.exit(1)
154
155         repo_url = repo_base + '/repo'
156         git_mirror_path = os.path.join(repo_basedir, 'git-mirror')
157         git_mirror_repodir = os.path.join(git_mirror_path, 'fdroid', 'repo')
158         git_mirror_metadatadir = os.path.join(git_mirror_path, 'fdroid', 'metadata')
159         if not os.path.isdir(git_mirror_repodir):
160             logging.debug(_('cloning {url}').format(url=clone_url))
161             try:
162                 git.Repo.clone_from(clone_url, git_mirror_path)
163             except Exception:
164                 pass
165         if not os.path.isdir(git_mirror_repodir):
166             os.makedirs(git_mirror_repodir, mode=0o755)
167
168         mirror_git_repo = git.Repo.init(git_mirror_path)
169         writer = mirror_git_repo.config_writer()
170         writer.set_value('user', 'name', git_user_name)
171         writer.set_value('user', 'email', git_user_email)
172         writer.release()
173         for remote in mirror_git_repo.remotes:
174             mirror_git_repo.delete_remote(remote)
175
176         readme_path = os.path.join(git_mirror_path, 'README.md')
177         readme = '''
178 # {repo_git_base}
179
180 [![{repo_url}](icon.png)]({repo_url})
181
182 Last updated: {date}'''.format(repo_git_base=repo_git_base,
183                                repo_url=repo_url,
184                                date=datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC'))
185         with open(readme_path, 'w') as fp:
186             fp.write(readme)
187         mirror_git_repo.git.add(all=True)
188         mirror_git_repo.index.commit("update README")
189
190         icon_path = os.path.join(git_mirror_path, 'icon.png')
191         try:
192             import qrcode
193             img = qrcode.make(repo_url)
194             with open(icon_path, 'wb') as fp:
195                 fp.write(img)
196         except Exception:
197             exampleicon = os.path.join(common.get_examples_dir(), 'fdroid-icon.png')
198             shutil.copy(exampleicon, icon_path)
199         mirror_git_repo.git.add(all=True)
200         mirror_git_repo.index.commit("update repo/website icon")
201         shutil.copy(icon_path, repo_basedir)
202
203         os.chdir(repo_basedir)
204         if os.path.isdir(git_mirror_repodir):
205             common.local_rsync(options, git_mirror_repodir + '/', 'repo/')
206         if os.path.isdir(git_mirror_metadatadir):
207             common.local_rsync(options, git_mirror_metadatadir + '/', 'metadata/')
208
209         ssh_private_key_file = _ssh_key_from_debug_keystore()
210         # this is needed for GitPython to find the SSH key
211         ssh_dir = os.path.join(os.getenv('HOME'), '.ssh')
212         os.makedirs(ssh_dir, exist_ok=True)
213         ssh_config = os.path.join(ssh_dir, 'config')
214         logging.debug(_('adding IdentityFile to {path}').format(path=ssh_config))
215         with open(ssh_config, 'a') as fp:
216             fp.write('\n\nHost *\n\tIdentityFile %s\n' % ssh_private_key_file)
217
218         config = ''
219         config += "identity_file = '%s'\n" % ssh_private_key_file
220         config += "repo_name = '%s'\n" % repo_git_base
221         config += "repo_url = '%s'\n" % repo_url
222         config += "repo_icon = 'icon.png'\n"
223         config += "archive_name = '%s'\n" % (repo_git_base + ' archive')
224         config += "archive_url = '%s'\n" % (repo_base + '/archive')
225         config += "archive_icon = 'icon.png'\n"
226         config += "servergitmirrors = '%s'\n" % servergitmirror
227         config += "keystore = '%s'\n" % KEYSTORE_FILE
228         config += "repo_keyalias = '%s'\n" % KEY_ALIAS
229         config += "keystorepass = '%s'\n" % PASSWORD
230         config += "keypass = '%s'\n" % PASSWORD
231         config += "keydname = '%s'\n" % DISTINGUISHED_NAME
232         config += "make_current_version_link = False\n"
233         config += "accepted_formats = ('txt', 'yml')\n"
234         # TODO add update_stats = True
235         with open('config.py', 'w') as fp:
236             fp.write(config)
237         os.chmod('config.py', 0o600)
238
239         for root, dirs, files in os.walk(cibase):
240             for d in ('fdroid', '.git', '.gradle'):
241                 if d in dirs:
242                     dirs.remove(d)
243             for f in files:
244                 if f.endswith('-debug.apk'):
245                     apkfilename = os.path.join(root, f)
246                     logging.debug(_('copying {apkfilename} into {path}')
247                                   .format(apkfilename=apkfilename, path=repodir))
248                     destapk = os.path.join(repodir, os.path.basename(f))
249                     shutil.copyfile(apkfilename, destapk)
250                     shutil.copystat(apkfilename, destapk)
251                     os.chmod(destapk, 0o644)
252
253         if options.verbose:
254             logging.debug(_('attempting bare ssh connection to test deploy key:'))
255             try:
256                 subprocess.check_call(['ssh', '-Tvi', ssh_private_key_file,
257                                        '-oIdentitiesOnly=yes', '-oStrictHostKeyChecking=no',
258                                        servergitmirror.split(':')[0]])
259             except subprocess.CalledProcessError:
260                 pass
261
262         subprocess.check_call(['fdroid', 'update', '--rename-apks', '--create-metadata', '--verbose'],
263                               cwd=repo_basedir)
264         common.local_rsync(options, repo_basedir + '/metadata/', git_mirror_metadatadir + '/')
265         mirror_git_repo.git.add(all=True)
266         mirror_git_repo.index.commit("update app metadata")
267         try:
268             subprocess.check_call(['fdroid', 'server', 'update', '--verbose'], cwd=repo_basedir)
269         except subprocess.CalledProcessError:
270             logging.error(_('cannot publish update, did you set the deploy key?')
271                           + '\n' + deploy_key_url)
272             sys.exit(1)
273         if shutil.rmtree.avoids_symlink_attacks:
274             shutil.rmtree(os.path.dirname(ssh_private_key_file))
275
276     else:
277         ssh_dir = os.path.join(os.getenv('HOME'), '.ssh')
278         os.makedirs(os.path.dirname(ssh_dir), exist_ok=True)
279         privkey = _ssh_key_from_debug_keystore()
280         ssh_private_key_file = os.path.join(ssh_dir, os.path.basename(privkey))
281         os.rename(privkey, ssh_private_key_file)
282         os.rename(privkey + '.pub', ssh_private_key_file + '.pub')
283         if shutil.rmtree.avoids_symlink_attacks:
284             shutil.rmtree(os.path.dirname(privkey))
285
286         if options.show_secret_var:
287             with open(KEYSTORE_FILE, 'rb') as fp:
288                 debug_keystore = base64.standard_b64encode(fp.read()).decode('ascii')
289             print(_('\n{path} encoded for the DEBUG_KEYSTORE secret variable:')
290                   .format(path=KEYSTORE_FILE))
291             print(debug_keystore)
292
293     os.umask(umask)
294
295
296 if __name__ == "__main__":
297     main()