Rename notarize -> notarize_dmg.
[cdist.git] / cdist
1 #!/usr/bin/python3
2
3 #    Copyright (C) 2012-2020 Carl Hetherington <cth@carlh.net>
4 #
5 #    This program is free software; you can redistribute it and/or modify
6 #    it under the terms of the GNU General Public License as published by
7 #    the Free Software Foundation; either version 2 of the License, or
8 #    (at your option) any later version.
9 #
10 #    This program is distributed in the hope that it will be useful,
11 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
12 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 #    GNU General Public License for more details.
14
15 #    You should have received a copy of the GNU General Public License
16 #    along with this program; if not, write to the Free Software
17 #    Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
18
19 from __future__ import print_function
20
21 import argparse
22 import copy
23 import datetime
24 import getpass
25 import glob
26 import inspect
27 import multiprocessing
28 import os
29 import platform
30 import re
31 import shlex
32 import shutil
33 import subprocess
34 import sys
35 import tempfile
36 import time
37
38 class Error(Exception):
39     def __init__(self, value):
40         self.value = value
41     def __str__(self):
42         return self.value
43     def __repr__(self):
44         return str(self)
45
46 class Trees:
47     """
48     Store for Tree objects which re-uses already-created objects
49     and checks for requests for different versions of the same thing.
50     """
51
52     def __init__(self):
53         self.trees = []
54
55     def get(self, name, specifier, target, required_by=None):
56         for t in self.trees:
57             if t.name == name and t.specifier == specifier and t.target == target:
58                 return t
59             elif t.name == name and t.specifier != specifier:
60                 a = specifier if specifier is not None else "[Any]"
61                 if required_by is not None:
62                     a += ' by %s' % required_by
63                 b = t.specifier if t.specifier is not None else "[Any]"
64                 if t.required_by is not None:
65                     b += ' by %s' % t.required_by
66                 raise Error('conflicting versions of %s required (%s versus %s)' % (name, a, b))
67
68         nt = Tree(name, specifier, target, required_by)
69         self.trees.append(nt)
70         return nt
71
72     def add_built(self, name, specifier, target):
73         self.trees.append(Tree(name, specifier, target, None, built=True))
74
75
76 class Globals:
77     quiet = False
78     command = None
79     dry_run = False
80     trees = Trees()
81
82 globals = Globals()
83
84
85 #
86 # Configuration
87 #
88
89 class Option(object):
90     def __init__(self, key, default=None):
91         self.key = key
92         self.value = default
93
94     def offer(self, key, value):
95         if key == self.key:
96             self.value = value
97
98 class BoolOption(object):
99     def __init__(self, key):
100         self.key = key
101         self.value = False
102
103     def offer(self, key, value):
104         if key == self.key:
105             self.value = (value == 'yes' or value == '1' or value == 'true')
106
107 class Config:
108     def __init__(self):
109         self.options = [ Option('mxe_prefix'),
110                          Option('git_prefix'),
111                          Option('git_reference'),
112                          Option('osx_environment_prefix'),
113                          Option('osx_sdk_prefix'),
114                          Option('osx_sdk'),
115                          Option('osx_keychain_file'),
116                          Option('osx_keychain_password'),
117                          Option('apple_id'),
118                          Option('apple_password'),
119                          BoolOption('docker_sudo'),
120                          BoolOption('docker_no_user'),
121                          Option('docker_hub_repository'),
122                          Option('flatpak_state_dir'),
123                          Option('parallel', multiprocessing.cpu_count()),
124                          Option('temp', '/var/tmp')]
125
126         config_dir = '%s/.config' % os.path.expanduser('~')
127         if not os.path.exists(config_dir):
128             os.mkdir(config_dir)
129         config_file = '%s/cdist' % config_dir
130         if not os.path.exists(config_file):
131             f = open(config_file, 'w')
132             for o in self.options:
133                 print('# %s ' % o.key, file=f)
134             f.close()
135             print('Template config file written to %s; please edit and try again.' % config_file, file=sys.stderr)
136             sys.exit(1)
137
138         try:
139             f = open('%s/.config/cdist' % os.path.expanduser('~'), 'r')
140             while True:
141                 l = f.readline()
142                 if l == '':
143                     break
144
145                 if len(l) > 0 and l[0] == '#':
146                     continue
147
148                 s = l.strip().split()
149                 if len(s) == 2:
150                     for k in self.options:
151                         k.offer(s[0], s[1])
152         except:
153             raise
154
155     def has(self, k):
156         for o in self.options:
157             if o.key == k and o.value is not None:
158                 return True
159         return False
160
161     def get(self, k):
162         for o in self.options:
163             if o.key == k:
164                 if o.value is None:
165                     raise Error('Required setting %s not found' % k)
166                 return o.value
167
168     def set(self, k, v):
169         for o in self.options:
170             o.offer(k, v)
171
172     def docker(self):
173         if self.get('docker_sudo'):
174             return 'sudo docker'
175         else:
176             return 'docker'
177
178 config = Config()
179
180 #
181 # Utility bits
182 #
183
184 def log_normal(m):
185     if not globals.quiet:
186         print('\x1b[33m* %s\x1b[0m' % m)
187
188 def log_verbose(m):
189     if globals.verbose:
190         print('\x1b[35m* %s\x1b[0m' % m)
191
192 def escape_spaces(s):
193     return s.replace(' ', '\\ ')
194
195 def scp_escape(n):
196     """Escape a host:filename string for use with an scp command"""
197     s = n.split(':')
198     assert(len(s) == 1 or len(s) == 2)
199     if len(s) == 2:
200         return '%s:"\'%s\'"' % (s[0], s[1])
201     else:
202         return '\"%s\"' % s[0]
203
204 def mv_escape(n):
205     return '\"%s\"' % n.substr(' ', '\\ ')
206
207 def copytree(a, b):
208     log_normal('copy %s -> %s' % (scp_escape(a), scp_escape(b)))
209     if b.startswith('s3://'):
210         command('s3cmd -P -r put "%s" "%s"' % (a, b))
211     else:
212         command('scp -r %s %s' % (scp_escape(a), scp_escape(b)))
213
214 def copyfile(a, b):
215     log_normal('copy %s -> %s with cwd %s' % (scp_escape(a), scp_escape(b), os.getcwd()))
216     if b.startswith('s3://'):
217         command('s3cmd -P put "%s" "%s"' % (a, b))
218     else:
219         bc = b.find(":")
220         if bc != -1:
221             host = b[:bc]
222             path = b[bc+1:]
223             temp_path = os.path.join(os.path.dirname(path), ".tmp." + os.path.basename(path))
224             command('scp %s %s' % (scp_escape(a), scp_escape(host + ":" + temp_path)))
225             command('ssh %s -- mv "%s" "%s"' % (host, escape_spaces(temp_path), escape_spaces(path)))
226         else:
227             command('scp %s %s' % (scp_escape(a), scp_escape(b)))
228
229 def makedirs(d):
230     """
231     Make directories either locally or on a remote host; remotely if
232     d includes a colon, otherwise locally.
233     """
234     if d.startswith('s3://'):
235         # No need to create folders on S3
236         return
237
238     if d.find(':') == -1:
239         try:
240             os.makedirs(d)
241         except OSError as e:
242             if e.errno != 17:
243                 raise e
244     else:
245         s = d.split(':')
246         command('ssh %s -- mkdir -p %s' % (s[0], s[1]))
247
248 def rmdir(a):
249     log_normal('remove %s' % a)
250     os.rmdir(a)
251
252 def rmtree(a):
253     log_normal('remove %s' % a)
254     shutil.rmtree(a, ignore_errors=True)
255
256 def command(c):
257     log_normal(c)
258     try:
259         r = subprocess.run(c, shell=True)
260         if r.returncode != 0:
261             raise Error('command %s failed (%d)' % (c, r.returncode))
262     except Exception as e:
263         raise Error('command %s failed (%s)' % (c, e))
264
265 def command_and_read(c):
266     log_normal(c)
267     p = subprocess.Popen(c.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
268     (out, err) = p.communicate()
269     if p.returncode != 0:
270         raise Error('command %s failed (%s)' % (c, err))
271     return str(out, 'utf-8').splitlines()
272
273 def read_wscript_variable(directory, variable):
274     f = open('%s/wscript' % directory, 'r')
275     while True:
276         l = f.readline()
277         if l == '':
278             break
279
280         s = l.split()
281         if len(s) == 3 and s[0] == variable:
282             f.close()
283             return s[2][1:-1]
284
285     f.close()
286     return None
287
288
289 def devel_to_git(git_commit, filename):
290     if git_commit is not None:
291         filename = filename.replace('devel', '-%s' % git_commit)
292     return filename
293
294
295 def get_command_line_options(args):
296     """Get the options specified by --option on the command line"""
297     options = dict()
298     if args.option is not None:
299         for o in args.option:
300             b = o.split(':')
301             if len(b) != 2:
302                 raise Error("Bad option `%s'" % o)
303             if b[1] == 'False':
304                 options[b[0]] = False
305             elif b[1] == 'True':
306                 options[b[0]] = True
307             else:
308                 options[b[0]] = b[1]
309     return options
310
311
312 class TreeDirectory:
313     def __init__(self, tree):
314         self.tree = tree
315     def __enter__(self):
316         self.cwd = os.getcwd()
317         os.chdir('%s/src/%s' % (self.tree.target.directory, self.tree.name))
318     def __exit__(self, type, value, traceback):
319         os.chdir(self.cwd)
320
321 #
322 # Version
323 #
324
325 class Version:
326     def __init__(self, s):
327         self.devel = False
328
329         if s.startswith("'"):
330             s = s[1:]
331         if s.endswith("'"):
332             s = s[0:-1]
333
334         if s.endswith('devel'):
335             s = s[0:-5]
336             self.devel = True
337
338         if s.endswith('pre'):
339             s = s[0:-3]
340
341         p = s.split('.')
342         self.major = int(p[0])
343         self.minor = int(p[1])
344         if len(p) == 3:
345             self.micro = int(p[2])
346         else:
347             self.micro = 0
348
349     @classmethod
350     def from_git_tag(cls, tag):
351         bits = tag.split('-')
352         c = cls(bits[0])
353         if len(bits) > 1 and int(bits[1]) > 0:
354             c.devel = True
355         return c
356
357     def bump_minor(self):
358         self.minor += 1
359         self.micro = 0
360
361     def bump_micro(self):
362         self.micro += 1
363
364     def to_devel(self):
365         self.devel = True
366
367     def to_release(self):
368         self.devel = False
369
370     def __str__(self):
371         s = '%d.%d.%d' % (self.major, self.minor, self.micro)
372         if self.devel:
373             s += 'devel'
374
375         return s
376
377 #
378 # Targets
379 #
380
381 class Target(object):
382     """
383     Class representing the target that we are building for.  This is exposed to cscripts,
384     though not all of it is guaranteed 'API'.  cscripts may expect:
385
386     platform: platform string (e.g. 'windows', 'linux', 'osx')
387     parallel: number of parallel jobs to run
388     directory: directory to work in
389     variables: dict of environment variables
390     debug: True to build a debug version, otherwise False
391     ccache: True to use ccache, False to not
392     set(a, b): set the value of variable 'a' to 'b'
393     unset(a): unset the value of variable 'a'
394     command(c): run the command 'c' in the build environment
395
396     """
397
398     def __init__(self, platform, directory=None):
399         """
400         platform -- platform string (e.g. 'windows', 'linux', 'osx')
401         directory -- directory to work in; if None we will use a temporary directory
402         Temporary directories will be removed after use; specified directories will not.
403         """
404         self.platform = platform
405         self.parallel = int(config.get('parallel'))
406
407         # Environment variables that we will use when we call cscripts
408         self.variables = {}
409         self.debug = False
410         self._ccache = False
411         # True to build our dependencies ourselves; False if this is taken care
412         # of in some other way
413         self.build_dependencies = True
414
415         if directory is None:
416             self.directory = tempfile.mkdtemp('', 'tmp', config.get('temp'))
417             self.rmdir = True
418             self.set('CCACHE_BASEDIR', os.path.realpath(self.directory))
419             self.set('CCACHE_NOHASHDIR', '')
420         else:
421             self.directory = os.path.realpath(directory)
422             self.rmdir = False
423
424
425     def setup(self):
426         pass
427
428     def _build_packages(self, tree, options):
429         if len(inspect.getfullargspec(tree.cscript['package']).args) == 3:
430             packages = tree.call('package', tree.version, options)
431         else:
432             log_normal("Deprecated cscript package() method with no options parameter")
433             packages = tree.call('package', tree.version)
434
435         return packages if isinstance(packages, list) else [packages]
436
437     def _copy_packages(self, tree, packages, output_dir):
438         for p in packages:
439             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
440
441     def package(self, project, checkout, output_dir, options, no_notarize):
442         tree = self.build(project, checkout, options)
443         tree.add_defaults(options)
444         p = self._build_packages(tree, options)
445         self._copy_packages(tree, p, output_dir)
446
447     def build(self, project, checkout, options):
448         tree = globals.trees.get(project, checkout, self)
449         if self.build_dependencies:
450             tree.build_dependencies(options)
451         tree.build(options)
452         return tree
453
454     def test(self, project, checkout, target, test, options):
455         """test is the test case to run, or None"""
456         tree = globals.trees.get(project, checkout, target)
457
458         tree.add_defaults(options)
459         with TreeDirectory(tree):
460             if len(inspect.getfullargspec(tree.cscript['test']).args) == 3:
461                 return tree.call('test', options, test)
462             else:
463                 log_normal('Deprecated cscript test() method with no options parameter')
464                 return tree.call('test', test)
465
466     def set(self, a, b):
467         self.variables[a] = b
468
469     def unset(self, a):
470         del(self.variables[a])
471
472     def get(self, a):
473         return self.variables[a]
474
475     def append(self, k, v, s):
476         if (not k in self.variables) or len(self.variables[k]) == 0:
477             self.variables[k] = '"%s"' % v
478         else:
479             e = self.variables[k]
480             if e[0] == '"' and e[-1] == '"':
481                 self.variables[k] = '"%s%s%s"' % (e[1:-1], s, v)
482             else:
483                 self.variables[k] = '"%s%s%s"' % (e, s, v)
484
485     def append_with_space(self, k, v):
486         return self.append(k, v, ' ')
487
488     def append_with_colon(self, k, v):
489         return self.append(k, v, ':')
490
491     def variables_string(self, escaped_quotes=False):
492         e = ''
493         for k, v in self.variables.items():
494             if escaped_quotes:
495                 v = v.replace('"', '\\"')
496             e += '%s=%s ' % (k, v)
497         return e
498
499     def cleanup(self):
500         if self.rmdir:
501             rmtree(self.directory)
502
503     def mount(self, m):
504         pass
505
506     @property
507     def ccache(self):
508         return self._ccache
509
510     @ccache.setter
511     def ccache(self, v):
512         self._ccache = v
513
514
515 class DockerTarget(Target):
516     def __init__(self, platform, directory):
517         super(DockerTarget, self).__init__(platform, directory)
518         self.mounts = []
519         self.privileged = False
520
521     def _user_tag(self):
522         if config.get('docker_no_user'):
523             return ''
524         return '-u %s' % getpass.getuser()
525
526     def _mount_option(self, d):
527         return '-v %s:%s ' % (os.path.realpath(d), os.path.realpath(d))
528
529     def setup(self):
530         opts = self._mount_option(self.directory)
531         for m in self.mounts:
532             opts += self._mount_option(m)
533         if config.has('git_reference'):
534             opts += self._mount_option(config.get('git_reference'))
535         if self.privileged:
536             opts += '--privileged=true '
537         if self.ccache:
538             opts += "-e CCACHE_DIR=/ccache/%s-%d --mount source=ccache,target=/ccache" % (self.image, os.getuid())
539
540         tag = self.image
541         if config.has('docker_hub_repository'):
542             tag = '%s:%s' % (config.get('docker_hub_repository'), tag)
543
544         self.container = command_and_read('%s run %s %s -itd %s /bin/bash' % (config.docker(), self._user_tag(), opts, tag))[0].strip()
545
546     def command(self, cmd):
547         dir = os.path.join(self.directory, os.path.relpath(os.getcwd(), self.directory))
548         interactive_flag = '-i ' if sys.stdin.isatty() else ''
549         command('%s exec %s %s -t %s /bin/bash -c \'export %s; cd %s; %s\'' % (config.docker(), self._user_tag(), interactive_flag, self.container, self.variables_string(), dir, cmd))
550
551     def cleanup(self):
552         super(DockerTarget, self).cleanup()
553         command('%s kill %s' % (config.docker(), self.container))
554
555     def mount(self, m):
556         self.mounts.append(m)
557
558
559 class FlatpakTarget(Target):
560     def __init__(self, project, checkout):
561         super(FlatpakTarget, self).__init__('flatpak')
562         self.build_dependencies = False
563         self.project = project
564         self.checkout = checkout
565
566     def setup(self):
567         pass
568
569     def command(self, cmd):
570         command(cmd)
571
572     def checkout_dependencies(self):
573         tree = globals.trees.get(self.project, self.checkout, self)
574         return tree.checkout_dependencies()
575
576     def flatpak(self):
577         return 'flatpak'
578
579     def flatpak_builder(self):
580         b = 'flatpak-builder'
581         if config.has('flatpak_state_dir'):
582             b += ' --state-dir=%s' % config.get('flatpak_state_dir')
583         return b
584
585
586 class WindowsDockerTarget(DockerTarget):
587     """
588     This target exposes the following additional API:
589
590     version: Windows version ('xp' or None)
591     bits: bitness of Windows (32 or 64)
592     name: name of our target e.g. x86_64-w64-mingw32.shared
593     environment_prefix: path to Windows environment for the appropriate target (libraries and some tools)
594     tool_path: path to 32- and 64-bit tools
595     """
596     def __init__(self, windows_version, bits, directory, environment_version):
597         super(WindowsDockerTarget, self).__init__('windows', directory)
598         self.version = windows_version
599         self.bits = bits
600
601         self.tool_path = '%s/usr/bin' % config.get('mxe_prefix')
602         if self.bits == 32:
603             self.name = 'i686-w64-mingw32.shared'
604         else:
605             self.name = 'x86_64-w64-mingw32.shared'
606         self.environment_prefix = '%s/usr/%s' % (config.get('mxe_prefix'), self.name)
607
608         self.set('PKG_CONFIG_LIBDIR', '%s/lib/pkgconfig' % self.environment_prefix)
609         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
610         self.set('PATH', '%s/bin:%s:%s' % (self.environment_prefix, self.tool_path, os.environ['PATH']))
611         self.set('LD', '%s-ld' % self.name)
612         self.set('RANLIB', '%s-ranlib' % self.name)
613         self.set('WINRC', '%s-windres' % self.name)
614         cxx = '-I%s/include -I%s/include' % (self.environment_prefix, self.directory)
615         link = '-L%s/lib -L%s/lib' % (self.environment_prefix, self.directory)
616         self.set('CXXFLAGS', '"%s"' % cxx)
617         self.set('CPPFLAGS', '')
618         self.set('LINKFLAGS', '"%s"' % link)
619         self.set('LDFLAGS', '"%s"' % link)
620
621         self.image = 'windows'
622         if environment_version is not None:
623             self.image += '_%s' % environment_version
624
625     def setup(self):
626         super().setup()
627         if self.ccache:
628             self.set('CC', '"ccache %s-gcc"' % self.name)
629             self.set('CXX', '"ccache %s-g++"' % self.name)
630         else:
631             self.set('CC', '%s-gcc' % self.name)
632             self.set('CXX', '%s-g++' % self.name)
633
634     @property
635     def library_prefix(self):
636         log_normal('Deprecated property library_prefix: use environment_prefix')
637         return self.environment_prefix
638
639     @property
640     def windows_prefix(self):
641         log_normal('Deprecated property windows_prefix: use environment_prefix')
642         return self.environment_prefix
643
644     @property
645     def mingw_prefixes(self):
646         log_normal('Deprecated property mingw_prefixes: use environment_prefix')
647         return [self.environment_prefix]
648
649     @property
650     def mingw_path(self):
651         log_normal('Deprecated property mingw_path: use tool_path')
652         return self.tool_path
653
654     @property
655     def mingw_name(self):
656         log_normal('Deprecated property mingw_name: use name')
657         return self.name
658
659
660 class WindowsNativeTarget(Target):
661     """
662     This target exposes the following additional API:
663
664     version: Windows version ('xp' or None)
665     bits: bitness of Windows (32 or 64)
666     name: name of our target e.g. x86_64-w64-mingw32.shared
667     environment_prefix: path to Windows environment for the appropriate target (libraries and some tools)
668     """
669     def __init__(self, directory):
670         super().__init__('windows', directory)
671         self.version = None
672         self.bits = 64
673
674         self.environment_prefix = config.get('windows_native_environmnet_prefix')
675
676         self.set('PATH', '%s/bin:%s' % (self.environment_prefix, os.environ['PATH']))
677
678     def command(self, cmd):
679         command(cmd)
680
681
682 class LinuxTarget(DockerTarget):
683     """
684     Build for Linux in a docker container.
685     This target exposes the following additional API:
686
687     distro: distribution ('debian', 'ubuntu', 'centos' or 'fedora')
688     version: distribution version (e.g. '12.04', '8', '6.5')
689     bits: bitness of the distribution (32 or 64)
690     detail: None or 'appimage' if we are building for appimage
691     """
692
693     def __init__(self, distro, version, bits, directory=None):
694         super(LinuxTarget, self).__init__('linux', directory)
695         self.distro = distro
696         self.version = version
697         self.bits = bits
698         self.detail = None
699
700         self.set('CXXFLAGS', '-I%s/include' % self.directory)
701         self.set('CPPFLAGS', '')
702         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
703         self.set('PKG_CONFIG_PATH',
704                  '%s/lib/pkgconfig:%s/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/local/lib/pkgconfig' % (self.directory, self.directory))
705         self.set('PATH', '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin')
706
707         if self.version is None:
708             self.image = '%s-%s' % (self.distro, self.bits)
709         else:
710             self.image = '%s-%s-%s' % (self.distro, self.version, self.bits)
711
712     def setup(self):
713         super(LinuxTarget, self).setup()
714         if self.ccache:
715             self.set('CC', '"ccache gcc"')
716             self.set('CXX', '"ccache g++"')
717
718     def test(self, project, checkout, target, test, options):
719         self.append_with_colon('PATH', '%s/bin' % self.directory)
720         self.append_with_colon('LD_LIBRARY_PATH', '%s/lib' % self.directory)
721         super(LinuxTarget, self).test(project, checkout, target, test, options)
722
723
724 class AppImageTarget(LinuxTarget):
725     def __init__(self, work):
726         super(AppImageTarget, self).__init__('ubuntu', '18.04', 64, work)
727         self.detail = 'appimage'
728         self.privileged = True
729
730
731 def notarize_dmg(dmg, bundle_id):
732     p = subprocess.run(
733         ['xcrun', 'altool', '--notarize-app', '-t', 'osx', '-f', dmg, '--primary-bundle-id', bundle_id, '-u', config.get('apple_id'), '-p', config.get('apple_password'), '--output-format', 'xml'],
734         capture_output=True
735         )
736
737     def string_after(process, key):
738         lines = p.stdout.decode('utf-8').splitlines()
739         for i in range(0, len(lines)):
740             if lines[i].find(key) != -1:
741                 return lines[i+1].strip().replace('<string>', '').replace('</string>', '')
742
743     request_uuid = string_after(p, "RequestUUID")
744     if request_uuid is None:
745         raise Error('No RequestUUID found in response from Apple')
746
747     for i in range(0, 30):
748         print('Checking up on %s' % request_uuid)
749         p = subprocess.run(['xcrun', 'altool', '--notarization-info', request_uuid, '-u', config.get('apple_id'), '-p', config.get('apple_password'), '--output-format', 'xml'], capture_output=True)
750         status = string_after(p, 'Status')
751         print('Got %s' % status)
752         if status == 'invalid':
753             raise Error("Notarization failed")
754         elif status == 'success':
755             subprocess.run(['xcrun', 'stapler', 'staple', dmg])
756             return
757         elif status != "in progress":
758             print("Could not understand xcrun response")
759             print(p)
760         time.sleep(30)
761
762     raise Error("Notarization timed out")
763
764
765 class OSXTarget(Target):
766     def __init__(self, directory=None):
767         super(OSXTarget, self).__init__('osx', directory)
768         self.sdk_prefix = config.get('osx_sdk_prefix')
769         self.environment_prefix = config.get('osx_environment_prefix')
770         self.apple_id = config.get('apple_id')
771         self.apple_password = config.get('apple_password')
772         self.osx_keychain_file = config.get('osx_keychain_file')
773         self.osx_keychain_password = config.get('osx_keychain_password')
774
775     def command(self, c):
776         command('%s %s' % (self.variables_string(False), c))
777
778     def unlock_keychain(self):
779         self.command('security unlock-keychain -p %s %s' % (self.osx_keychain_password, self.osx_keychain_file))
780
781
782 class OSXSingleTarget(OSXTarget):
783     def __init__(self, arch, sdk, directory=None):
784         super(OSXSingleTarget, self).__init__(directory)
785         self.arch = arch
786         self.sdk = sdk
787
788         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (self.sdk_prefix, sdk, arch)
789         host_enviro = '%s/x86_64' % config.get('osx_environment_prefix')
790         target_enviro = '%s/%s' % (config.get('osx_environment_prefix'), arch)
791
792         self.bin = '%s/bin' % target_enviro
793
794         # Environment variables
795         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, target_enviro, flags))
796         self.set('CPPFLAGS', '')
797         self.set('CXXFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, target_enviro, flags))
798         self.set('LDFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, target_enviro, flags))
799         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, target_enviro, flags))
800         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, target_enviro))
801         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % host_enviro)
802         self.set('MACOSX_DEPLOYMENT_TARGET', sdk)
803         self.set('CCACHE_BASEDIR', self.directory)
804
805     @Target.ccache.setter
806     def ccache(self, v):
807         Target.ccache.fset(self, v)
808         if v:
809             self.set('CC', '"ccache gcc"')
810             self.set('CXX', '"ccache g++"')
811
812     def package(self, project, checkout, output_dir, options, no_notarize):
813         tree = self.build(project, checkout, options)
814         tree.add_defaults(options)
815         self.unlock_keychain()
816         p = self._build_packages(tree, options)
817         for x in p:
818             if not isinstance(x, tuple):
819                 raise Error('macOS packages must be returned from cscript as tuples of (dmg-filename, bundle-id)')
820             if not no_notarize:
821                 notarize_dmg(x[0], x[1])
822         self._copy_packages(tree, [x[0] for x in p], output_dir)
823
824
825 class OSXUniversalTarget(OSXTarget):
826     def __init__(self, archs, directory=None):
827         super(OSXUniversalTarget, self).__init__(directory)
828         self.archs = archs
829
830     def package(self, project, checkout, output_dir, options, no_notarize):
831
832         sdk = config.get('osx_sdk')
833         for a in self.archs:
834             if a.find('arm') != -1:
835                 sdk = '11.0'
836
837         for a in self.archs:
838             target = OSXSingleTarget(a, sdk, os.path.join(self.directory, a))
839             target.ccache = self.ccache
840             tree = globals.trees.get(project, checkout, target)
841             tree.build_dependencies(options)
842             tree.build(options)
843
844         self.unlock_keychain()
845         tree = globals.trees.get(project, checkout, self)
846         with TreeDirectory(tree):
847             self._build_packages(tree, options)
848             for p in packages:
849                 copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
850
851 class SourceTarget(Target):
852     """Build a source .tar.bz2"""
853     def __init__(self):
854         super(SourceTarget, self).__init__('source')
855
856     def command(self, c):
857         log_normal('host -> %s' % c)
858         command('%s %s' % (self.variables_string(), c))
859
860     def cleanup(self):
861         rmtree(self.directory)
862
863     def package(self, project, checkout, output_dir, options, no_notarize):
864         tree = globals.trees.get(project, checkout, self)
865         with TreeDirectory(tree):
866             name = read_wscript_variable(os.getcwd(), 'APPNAME')
867             command('./waf dist')
868             p = os.path.abspath('%s-%s.tar.bz2' % (name, tree.version))
869             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
870
871 # @param s Target string:
872 #       windows-{32,64}
873 #    or ubuntu-version-{32,64}
874 #    or debian-version-{32,64}
875 #    or centos-version-{32,64}
876 #    or fedora-version-{32,64}
877 #    or mageia-version-{32,64}
878 #    or osx-{intel,arm}
879 #    or source
880 #    or flatpak
881 #    or appimage
882 # @param debug True to build with debugging symbols (where possible)
883 def target_factory(args):
884     s = args.target
885     target = None
886     if s.startswith('windows-'):
887         x = s.split('-')
888         if platform.system() == "Windows":
889             target = WindowsNativeTarget(args.work)
890         else:
891             if len(x) == 2:
892                 target = WindowsDockerTarget(None, int(x[1]), args.work, args.environment_version)
893             elif len(x) == 3:
894                 target = WindowsDockerTarget(x[1], int(x[2]), args.work, args.environment_version)
895             else:
896                 raise Error("Bad Windows target name `%s'")
897     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-') or s.startswith('fedora-') or s.startswith('mageia-'):
898         p = s.split('-')
899         if len(p) != 3:
900             raise Error("Bad Linux target name `%s'; must be something like ubuntu-16.04-32 (i.e. distro-version-bits)" % s)
901         target = LinuxTarget(p[0], p[1], int(p[2]), args.work)
902     elif s.startswith('arch-'):
903         p = s.split('-')
904         if len(p) != 2:
905             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
906         target = LinuxTarget(p[0], None, int(p[1]), args.work)
907     elif s == 'raspbian':
908         target = LinuxTarget(s, None, None, args.work)
909     elif s == 'osx-intel-32-64':
910         # Universal Intel 32/64-bit built for config's os_sdk
911         if args.command == 'build':
912             target = OSXSingleTarget('x86_64', args.work)
913         else:
914             target = OSXUniversalTarget(('i386', 'x86_64'), args.work)
915     elif s == 'osx-arm-intel-64':
916         # Universal arm64 and Intel 64-bit built for SDK 11.0
917         target = OSXUniversalTarget(('arm64', 'x86_64'), args.work)
918     elif s == 'osx-arm64':
919         target = OSXSingleTarget('arm64', '11.0', args.work)
920     elif s == 'source':
921         target = SourceTarget()
922     elif s == 'flatpak':
923         target = FlatpakTarget(args.project, args.checkout)
924     elif s == 'appimage':
925         target = AppImageTarget(args.work)
926
927     if target is None:
928         raise Error("Bad target `%s'" % s)
929
930     target.debug = args.debug
931     target.ccache = args.ccache
932
933     if args.environment is not None:
934         for e in args.environment:
935             target.set(e, os.environ[e])
936
937     if args.mount is not None:
938         for m in args.mount:
939             target.mount(m)
940
941     target.setup()
942     return target
943
944
945 #
946 # Tree
947 #
948
949 class Tree(object):
950     """Description of a tree, which is a checkout of a project,
951        possibly built.  This class is never exposed to cscripts.
952        Attributes:
953            name -- name of git repository (without the .git)
954            specifier -- git tag or revision to use
955            target -- target object that we are using
956            version -- version from the wscript (if one is present)
957            git_commit -- git revision that is actually being used
958            built -- true if the tree has been built yet in this run
959            required_by -- name of the tree that requires this one
960     """
961
962     def __init__(self, name, specifier, target, required_by, built=False):
963         self.name = name
964         self.specifier = specifier
965         self.target = target
966         self.version = None
967         self.git_commit = None
968         self.built = built
969         self.required_by = required_by
970
971         cwd = os.getcwd()
972         proj = '%s/src/%s' % (target.directory, self.name)
973
974         if not built:
975             flags = ''
976             redirect = ''
977             if globals.quiet:
978                 flags = '-q'
979                 redirect = '>/dev/null'
980             if config.has('git_reference'):
981                 ref = '--reference-if-able %s/%s.git' % (config.get('git_reference'), self.name)
982             else:
983                 ref = ''
984             command('git clone %s %s %s/%s.git %s/src/%s' % (flags, ref, config.get('git_prefix'), self.name, target.directory, self.name))
985             os.chdir('%s/src/%s' % (target.directory, self.name))
986
987             spec = self.specifier
988             if spec is None:
989                 spec = 'master'
990
991             command('git checkout %s %s %s' % (flags, spec, redirect))
992             self.git_commit = command_and_read('git rev-parse --short=7 HEAD')[0].strip()
993
994         self.cscript = {}
995         exec(open('%s/cscript' % proj).read(), self.cscript)
996
997         if not built:
998             # cscript can include submodules = False to stop submodules being fetched
999             if (not 'submodules' in self.cscript or self.cscript['submodules'] == True) and os.path.exists('.gitmodules'):
1000                 command('git submodule --quiet init')
1001                 paths = command_and_read('git config --file .gitmodules --get-regexp path')
1002                 urls = command_and_read('git config --file .gitmodules --get-regexp url')
1003                 for path, url in zip(paths, urls):
1004                     ref = ''
1005                     if config.has('git_reference'):
1006                         url = url.split(' ')[1]
1007                         ref_path = os.path.join(config.get('git_reference'), os.path.basename(url))
1008                         if os.path.exists(ref_path):
1009                             ref = '--reference %s' % ref_path
1010                     path = path.split(' ')[1]
1011                     command('git submodule --quiet update %s %s' % (ref, path))
1012
1013         if os.path.exists('%s/wscript' % proj):
1014             v = read_wscript_variable(proj, "VERSION");
1015             if v is not None:
1016                 try:
1017                     self.version = Version(v)
1018                 except:
1019                     try:
1020                         tag = command_and_read('git -C %s describe --tags' % proj)[0][1:]
1021                         self.version = Version.from_git_tag(tag)
1022                     except:
1023                         # We'll leave version as None if we can't read it; maybe this is a bad idea
1024                         # Should probably just install git on the Windows VM
1025                         pass
1026
1027         os.chdir(cwd)
1028
1029     def call(self, function, *args):
1030         with TreeDirectory(self):
1031             return self.cscript[function](self.target, *args)
1032
1033     def add_defaults(self, options):
1034         """Add the defaults from self into a dict options"""
1035         if 'option_defaults' in self.cscript:
1036             from_cscript = self.cscript['option_defaults']
1037             if isinstance(from_cscript, dict):
1038                 defaults_dict = from_cscript
1039             else:
1040                 log_normal("Deprecated cscript option_defaults method; replace with a dict")
1041                 defaults_dict = from_cscript()
1042             for k, v in defaults_dict.items():
1043                 if not k in options:
1044                     options[k] = v
1045
1046     def dependencies(self, options):
1047         """
1048         yield details of the dependencies of this tree.  Each dependency is returned
1049         as a tuple of (tree, options, parent_tree).  The 'options' parameter are the options that
1050         we want to force for 'self'.
1051         """
1052         if not 'dependencies' in self.cscript:
1053             return
1054
1055         if len(inspect.getfullargspec(self.cscript['dependencies']).args) == 2:
1056             self_options = copy.copy(options)
1057             self.add_defaults(self_options)
1058             deps = self.call('dependencies', self_options)
1059         else:
1060             log_normal("Deprecated cscript dependencies() method with no options parameter")
1061             deps = self.call('dependencies')
1062
1063         # Loop over our immediate dependencies
1064         for d in deps:
1065             dep = globals.trees.get(d[0], d[1], self.target, self.name)
1066
1067             # deps only get their options from the parent's cscript
1068             dep_options = d[2] if len(d) > 2 else {}
1069             for i in dep.dependencies(dep_options):
1070                 yield i
1071             yield (dep, dep_options, self)
1072
1073     def checkout_dependencies(self, options={}):
1074         for i in self.dependencies(options):
1075             pass
1076
1077     def build_dependencies(self, options):
1078         """
1079         Called on the 'main' project tree (-p on the command line) to build all dependencies.
1080         'options' will be the ones from the command line.
1081         """
1082         for i in self.dependencies(options):
1083             i[0].build(i[1])
1084
1085     def build(self, options):
1086         if self.built:
1087             return
1088
1089         log_verbose("Building %s %s %s with %s" % (self.name, self.specifier, self.version, options))
1090
1091         variables = copy.copy(self.target.variables)
1092
1093         options = copy.copy(options)
1094         self.add_defaults(options)
1095
1096         if not globals.dry_run:
1097             if len(inspect.getfullargspec(self.cscript['build']).args) == 2:
1098                 self.call('build', options)
1099             else:
1100                 self.call('build')
1101
1102         self.target.variables = variables
1103         self.built = True
1104
1105
1106 #
1107 # Command-line parser
1108 #
1109
1110 def main():
1111
1112     commands = {
1113         "build": "build project",
1114         "package": "build and package the project",
1115         "release": "release a project using its next version number (adding a tag)",
1116         "pot": "build the project's .pot files",
1117         "manual": "build the project's manual",
1118         "doxygen": "build the project's Doxygen documentation",
1119         "latest": "print out the latest version",
1120         "test": "build the project and run its unit tests",
1121         "shell": "start a shell in the project''s work directory",
1122         "checkout": "check out the project",
1123         "revision": "print the head git revision number",
1124         "dependencies" : "print details of the project's dependencies as a .dot file"
1125     }
1126
1127     one_of = ""
1128     summary = ""
1129     for k, v in commands.items():
1130         one_of += "\t%s%s\n" % (k.ljust(20), v)
1131         summary += k + " "
1132
1133     parser = argparse.ArgumentParser()
1134     parser.add_argument('-p', '--project', help='project name')
1135     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
1136     parser.add_argument('-o', '--output', help='output directory', default='.')
1137     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
1138     parser.add_argument('-t', '--target', help='target', action='append')
1139     parser.add_argument('--environment-version', help='version of environment to use')
1140     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
1141     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
1142     parser.add_argument('-w', '--work', help='override default work directory')
1143     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
1144     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
1145     parser.add_argument('-e', '--environment', help='pass the value of the named environment variable into the build', action='append')
1146     parser.add_argument('-m', '--mount', help='mount a given directory in the build environment', action='append')
1147     parser.add_argument('--option', help='set an option for the build (use --option key:value)', action='append')
1148     parser.add_argument('--ccache', help='use ccache', action='store_true')
1149     parser.add_argument('--verbose', help='be verbose', action='store_true')
1150
1151     subparsers = parser.add_subparsers(help='command to run', dest='command')
1152     parser_build = subparsers.add_parser("build", help="build project")
1153     parser_package = subparsers.add_parser("package", help="build and package project")
1154     parser_package.add_argument('--no-notarize', help='do not notarize .dmg packages', action='store_true')
1155     parser_release = subparsers.add_parser("release", help="release a project using its next version number (adding a tag)")
1156     parser_release.add_argument('--minor', help='minor version number bump', action='store_true')
1157     parser_release.add_argument('--micro', help='micro version number bump', action='store_true')
1158     parser_pot = subparsers.add_parser("pot", help="build the project's .pot files")
1159     parser_manual = subparsers.add_parser("manual", help="build the project's manual")
1160     parser_doxygen = subparsers.add_parser("doxygen", help="build the project's Doxygen documentation")
1161     parser_latest = subparsers.add_parser("latest", help="print out the latest version")
1162     parser_latest.add_argument('--major', help='major version to return', type=int)
1163     parser_latest.add_argument('--minor', help='minor version to return', type=int)
1164     parser_test = subparsers.add_parser("test", help="build the project and run its unit tests")
1165     parser_test.add_argument('--no-implicit-build', help='do not build first', action='store_true')
1166     parser_test.add_argument('--test', help="name of test to run, defaults to all")
1167     parser_shell = subparsers.add_parser("shell", help="build the project then start a shell")
1168     parser_checkout = subparsers.add_parser("checkout", help="check out the project")
1169     parser_revision = subparsers.add_parser("revision", help="print the head git revision number")
1170     parser_dependencies = subparsers.add_parser("dependencies", help="print details of the project's dependencies as a .dot file")
1171
1172     global args
1173     args = parser.parse_args()
1174
1175     # Check for incorrect multiple parameters
1176     if args.target is not None:
1177         if len(args.target) > 1:
1178             parser.error('multiple -t options specified')
1179             sys.exit(1)
1180         else:
1181             args.target = args.target[0]
1182
1183     # Override configured stuff
1184     if args.git_prefix is not None:
1185         config.set('git_prefix', args.git_prefix)
1186
1187     if args.output.find(':') == -1:
1188         # This isn't of the form host:path so make it absolute
1189         args.output = os.path.abspath(args.output) + '/'
1190     else:
1191         if args.output[-1] != ':' and args.output[-1] != '/':
1192             args.output += '/'
1193
1194     # Now, args.output is 'host:', 'host:path/' or 'path/'
1195
1196     if args.work is not None:
1197         args.work = os.path.abspath(args.work)
1198         if not os.path.exists(args.work):
1199             os.makedirs(args.work)
1200
1201     if args.project is None and args.command != 'shell':
1202         raise Error('you must specify -p or --project')
1203
1204     globals.quiet = args.quiet
1205     globals.verbose = args.verbose
1206     globals.dry_run = args.dry_run
1207
1208     if args.command == 'build':
1209         if args.target is None:
1210             raise Error('you must specify -t or --target')
1211
1212         target = target_factory(args)
1213         target.build(args.project, args.checkout, get_command_line_options(args))
1214         if not args.keep:
1215             target.cleanup()
1216
1217     elif args.command == 'package':
1218         if args.target is None:
1219             raise Error('you must specify -t or --target')
1220
1221         target = None
1222         try:
1223             target = target_factory(args)
1224
1225             if target.platform == 'linux' and target.detail != "appimage":
1226                 if target.distro != 'arch':
1227                     output_dir = os.path.join(args.output, '%s-%s-%d' % (target.distro, target.version, target.bits))
1228                 else:
1229                     output_dir = os.path.join(args.output, '%s-%d' % (target.distro, target.bits))
1230             else:
1231                 output_dir = args.output
1232
1233             makedirs(output_dir)
1234             target.package(args.project, args.checkout, output_dir, get_command_line_options(args), args.no_notarize)
1235         except Error as e:
1236             if target is not None and not args.keep:
1237                 target.cleanup()
1238             raise
1239
1240         if target is not None and not args.keep:
1241             target.cleanup()
1242
1243     elif args.command == 'release':
1244         if args.minor is False and args.micro is False:
1245             raise Error('you must specify --minor or --micro')
1246
1247         target = SourceTarget()
1248         tree = globals.trees.get(args.project, args.checkout, target)
1249
1250         version = tree.version
1251         version.to_release()
1252         if args.minor:
1253             version.bump_minor()
1254         else:
1255             version.bump_micro()
1256
1257         with TreeDirectory(tree):
1258             command('git tag -m "v%s" v%s' % (version, version))
1259             command('git push --tags')
1260
1261         target.cleanup()
1262
1263     elif args.command == 'pot':
1264         target = SourceTarget()
1265         tree = globals.trees.get(args.project, args.checkout, target)
1266
1267         pots = tree.call('make_pot')
1268         for p in pots:
1269             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
1270
1271         target.cleanup()
1272
1273     elif args.command == 'manual':
1274         target = SourceTarget()
1275         tree = globals.trees.get(args.project, args.checkout, target)
1276
1277         outs = tree.call('make_manual')
1278         for o in outs:
1279             if os.path.isfile(o):
1280                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
1281             else:
1282                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
1283
1284         target.cleanup()
1285
1286     elif args.command == 'doxygen':
1287         target = SourceTarget()
1288         tree = globals.trees.get(args.project, args.checkout, target)
1289
1290         dirs = tree.call('make_doxygen')
1291         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
1292             dirs = [dirs]
1293
1294         for d in dirs:
1295             copytree(d, args.output)
1296
1297         target.cleanup()
1298
1299     elif args.command == 'latest':
1300         target = SourceTarget()
1301         tree = globals.trees.get(args.project, args.checkout, target)
1302
1303         with TreeDirectory(tree):
1304             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
1305             latest = None
1306             line = 0
1307             while latest is None:
1308                 t = f[line]
1309                 line += 1
1310                 m = re.compile(".*\((.*)\).*").match(t)
1311                 if m:
1312                     tags = m.group(1).split(', ')
1313                     for t in tags:
1314                         s = t.split()
1315                         if len(s) > 1:
1316                             t = s[1]
1317                         if len(t) > 0 and t[0] == 'v':
1318                             v = Version(t[1:])
1319                             if (args.major is None or v.major == args.major) and (args.minor is None or v.minor == args.minor):
1320                                 latest = v
1321
1322         print(latest)
1323         target.cleanup()
1324
1325     elif args.command == 'test':
1326         if args.target is None:
1327             raise Error('you must specify -t or --target')
1328
1329         target = None
1330         try:
1331             target = target_factory(args)
1332             options = get_command_line_options(args)
1333             if args.no_implicit_build:
1334                 globals.trees.add_built(args.project, args.checkout, target)
1335             else:
1336                 target.build(args.project, args.checkout, options)
1337             target.test(args.project, args.checkout, target, args.test, options)
1338         finally:
1339             if target is not None and not args.keep:
1340                 target.cleanup()
1341
1342     elif args.command == 'shell':
1343         if args.target is None:
1344             raise Error('you must specify -t or --target')
1345
1346         target = target_factory(args)
1347         target.command('bash')
1348
1349     elif args.command == 'revision':
1350
1351         target = SourceTarget()
1352         tree = globals.trees.get(args.project, args.checkout, target)
1353         with TreeDirectory(tree):
1354             print(command_and_read('git rev-parse HEAD')[0].strip()[:7])
1355         target.cleanup()
1356
1357     elif args.command == 'checkout':
1358
1359         if args.output is None:
1360             raise Error('you must specify -o or --output')
1361
1362         target = SourceTarget()
1363         tree = globals.trees.get(args.project, args.checkout, target)
1364         with TreeDirectory(tree):
1365             shutil.copytree('.', args.output)
1366         target.cleanup()
1367
1368     elif args.command == 'dependencies':
1369         if args.target is None:
1370             raise Error('you must specify -t or --target')
1371         if args.checkout is None:
1372             raise Error('you must specify -c or --checkout')
1373
1374         target = target_factory(args)
1375         tree = globals.trees.get(args.project, args.checkout, target)
1376         print("strict digraph {")
1377         for d in list(tree.dependencies({})):
1378             print("%s -> %s;" % (d[2].name.replace("-", "-"), d[0].name.replace("-", "_")))
1379         print("}")
1380
1381
1382 try:
1383     main()
1384 except Error as e:
1385     print('cdist: %s' % str(e), file=sys.stderr)
1386     sys.exit(1)