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