Support arm64 and building intel-only or intel/arm universal binaries.
[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, 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 build(self, *a, **k):
779         self.command('security unlock-keychain -p %s %s' % (self.osx_keychain_password, self.osx_keychain_file))
780         return super().build(*a, **k)
781
782
783 class OSXSingleTarget(OSXTarget):
784     def __init__(self, arch, sdk, directory=None):
785         super(OSXSingleTarget, self).__init__(directory)
786         self.arch = arch
787         self.sdk = sdk
788
789         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (self.sdk_prefix, sdk, arch)
790         host_enviro = '%s/x86_64' % config.get('osx_environment_prefix')
791         target_enviro = '%s/%s' % (config.get('osx_environment_prefix'), arch)
792
793         # Environment variables
794         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, target_enviro, flags))
795         self.set('CPPFLAGS', '')
796         self.set('CXXFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, target_enviro, flags))
797         self.set('LDFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, target_enviro, flags))
798         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, target_enviro, flags))
799         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, target_enviro))
800         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % host_enviro)
801         self.set('MACOSX_DEPLOYMENT_TARGET', sdk)
802         self.set('CCACHE_BASEDIR', self.directory)
803
804     @Target.ccache.setter
805     def ccache(self, v):
806         Target.ccache.fset(self, v)
807         if v:
808             self.set('CC', '"ccache gcc"')
809             self.set('CXX', '"ccache g++"')
810
811     def package(self, project, checkout, output_dir, options, no_notarize):
812         tree = self.build(project, checkout, options)
813         tree.add_defaults(options)
814         p = self._build_packages(tree, options)
815         for x in p:
816             if not isinstance(x, tuple):
817                 raise Error('macOS packages must be returned from cscript as tuples of (dmg-filename, bundle-id)')
818             if not no_notarize:
819                 notarize(x[0], x[1])
820         self._copy_packages(tree, [x[0] for x in p], output_dir)
821
822
823 class OSXUniversalTarget(OSXTarget):
824     def __init__(self, archs, directory=None):
825         super(OSXUniversalTarget, self).__init__(directory)
826         self.archs = archs
827
828     def package(self, project, checkout, output_dir, options, no_notarize):
829
830         sdk = config.get('osx_sdk')
831         for a in self.archs:
832             if a.find('arm') != -1:
833                 sdk = '11.0'
834
835         for a in self.archs:
836             target = OSXSingleTarget(a, sdk, os.path.join(self.directory, a))
837             target.ccache = self.ccache
838             tree = globals.trees.get(project, checkout, target)
839             tree.build_dependencies(options)
840             tree.build(options)
841
842         tree = globals.trees.get(project, checkout, self)
843         with TreeDirectory(tree):
844             if len(inspect.getfullargspec(tree.cscript['package']).args) == 3:
845                 packages = tree.call('package', tree.version, options)
846             else:
847                 log_normal("Deprecated cscript package() method with no options parameter")
848                 packages = tree.call('package', tree.version)
849             for p in packages:
850                 copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
851
852 class SourceTarget(Target):
853     """Build a source .tar.bz2"""
854     def __init__(self):
855         super(SourceTarget, self).__init__('source')
856
857     def command(self, c):
858         log_normal('host -> %s' % c)
859         command('%s %s' % (self.variables_string(), c))
860
861     def cleanup(self):
862         rmtree(self.directory)
863
864     def package(self, project, checkout, output_dir, options, no_notarize):
865         tree = globals.trees.get(project, checkout, self)
866         with TreeDirectory(tree):
867             name = read_wscript_variable(os.getcwd(), 'APPNAME')
868             command('./waf dist')
869             p = os.path.abspath('%s-%s.tar.bz2' % (name, tree.version))
870             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
871
872 # @param s Target string:
873 #       windows-{32,64}
874 #    or ubuntu-version-{32,64}
875 #    or debian-version-{32,64}
876 #    or centos-version-{32,64}
877 #    or fedora-version-{32,64}
878 #    or mageia-version-{32,64}
879 #    or osx-{intel,arm}
880 #    or source
881 #    or flatpak
882 #    or appimage
883 # @param debug True to build with debugging symbols (where possible)
884 def target_factory(args):
885     s = args.target
886     target = None
887     if s.startswith('windows-'):
888         x = s.split('-')
889         if platform.system() == "Windows":
890             target = WindowsNativeTarget(args.work)
891         else:
892             if len(x) == 2:
893                 target = WindowsDockerTarget(None, int(x[1]), args.work, args.environment_version)
894             elif len(x) == 3:
895                 target = WindowsDockerTarget(x[1], int(x[2]), args.work, args.environment_version)
896             else:
897                 raise Error("Bad Windows target name `%s'")
898     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-') or s.startswith('fedora-') or s.startswith('mageia-'):
899         p = s.split('-')
900         if len(p) != 3:
901             raise Error("Bad Linux target name `%s'; must be something like ubuntu-16.04-32 (i.e. distro-version-bits)" % s)
902         target = LinuxTarget(p[0], p[1], int(p[2]), args.work)
903     elif s.startswith('arch-'):
904         p = s.split('-')
905         if len(p) != 2:
906             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
907         target = LinuxTarget(p[0], None, int(p[1]), args.work)
908     elif s == 'raspbian':
909         target = LinuxTarget(s, None, None, args.work)
910     elif s == 'osx-intel':
911         # Universal Intel 32/64-bit
912         if args.command == 'build':
913             target = OSXSingleTarget('x86_64', args.work)
914         else:
915             target = OSXUniversalTarget(('i386', 'x86_64'), args.work)
916     elif s == 'osx-arm':
917         # Universal arm64 and Intel 64-bit
918         target = OSXUniversalTarget(('arm64', 'x86_64'), args.work)
919     elif s == 'source':
920         target = SourceTarget()
921     elif s == 'flatpak':
922         target = FlatpakTarget(args.project, args.checkout)
923     elif s == 'appimage':
924         target = AppImageTarget(args.work)
925
926     if target is None:
927         raise Error("Bad target `%s'" % s)
928
929     target.debug = args.debug
930     target.ccache = args.ccache
931
932     if args.environment is not None:
933         for e in args.environment:
934             target.set(e, os.environ[e])
935
936     if args.mount is not None:
937         for m in args.mount:
938             target.mount(m)
939
940     target.setup()
941     return target
942
943
944 #
945 # Tree
946 #
947
948 class Tree(object):
949     """Description of a tree, which is a checkout of a project,
950        possibly built.  This class is never exposed to cscripts.
951        Attributes:
952            name -- name of git repository (without the .git)
953            specifier -- git tag or revision to use
954            target -- target object that we are using
955            version -- version from the wscript (if one is present)
956            git_commit -- git revision that is actually being used
957            built -- true if the tree has been built yet in this run
958            required_by -- name of the tree that requires this one
959     """
960
961     def __init__(self, name, specifier, target, required_by, built=False):
962         self.name = name
963         self.specifier = specifier
964         self.target = target
965         self.version = None
966         self.git_commit = None
967         self.built = built
968         self.required_by = required_by
969
970         cwd = os.getcwd()
971         proj = '%s/src/%s' % (target.directory, self.name)
972
973         if not built:
974             flags = ''
975             redirect = ''
976             if globals.quiet:
977                 flags = '-q'
978                 redirect = '>/dev/null'
979             if config.has('git_reference'):
980                 ref = '--reference-if-able %s/%s.git' % (config.get('git_reference'), self.name)
981             else:
982                 ref = ''
983             command('git clone %s %s %s/%s.git %s/src/%s' % (flags, ref, config.get('git_prefix'), self.name, target.directory, self.name))
984             os.chdir('%s/src/%s' % (target.directory, self.name))
985
986             spec = self.specifier
987             if spec is None:
988                 spec = 'master'
989
990             command('git checkout %s %s %s' % (flags, spec, redirect))
991             self.git_commit = command_and_read('git rev-parse --short=7 HEAD')[0].strip()
992
993         self.cscript = {}
994         exec(open('%s/cscript' % proj).read(), self.cscript)
995
996         if not built:
997             # cscript can include submodules = False to stop submodules being fetched
998             if (not 'submodules' in self.cscript or self.cscript['submodules'] == True) and os.path.exists('.gitmodules'):
999                 command('git submodule --quiet init')
1000                 paths = command_and_read('git config --file .gitmodules --get-regexp path')
1001                 urls = command_and_read('git config --file .gitmodules --get-regexp url')
1002                 for path, url in zip(paths, urls):
1003                     ref = ''
1004                     if config.has('git_reference'):
1005                         url = url.split(' ')[1]
1006                         ref_path = os.path.join(config.get('git_reference'), os.path.basename(url))
1007                         if os.path.exists(ref_path):
1008                             ref = '--reference %s' % ref_path
1009                     path = path.split(' ')[1]
1010                     command('git submodule --quiet update %s %s' % (ref, path))
1011
1012         if os.path.exists('%s/wscript' % proj):
1013             v = read_wscript_variable(proj, "VERSION");
1014             if v is not None:
1015                 try:
1016                     self.version = Version(v)
1017                 except:
1018                     try:
1019                         tag = command_and_read('git -C %s describe --tags' % proj)[0][1:]
1020                         self.version = Version.from_git_tag(tag)
1021                     except:
1022                         # We'll leave version as None if we can't read it; maybe this is a bad idea
1023                         # Should probably just install git on the Windows VM
1024                         pass
1025
1026         os.chdir(cwd)
1027
1028     def call(self, function, *args):
1029         with TreeDirectory(self):
1030             return self.cscript[function](self.target, *args)
1031
1032     def add_defaults(self, options):
1033         """Add the defaults from self into a dict options"""
1034         if 'option_defaults' in self.cscript:
1035             from_cscript = self.cscript['option_defaults']
1036             if isinstance(from_cscript, dict):
1037                 defaults_dict = from_cscript
1038             else:
1039                 log_normal("Deprecated cscript option_defaults method; replace with a dict")
1040                 defaults_dict = from_cscript()
1041             for k, v in defaults_dict.items():
1042                 if not k in options:
1043                     options[k] = v
1044
1045     def dependencies(self, options):
1046         """
1047         yield details of the dependencies of this tree.  Each dependency is returned
1048         as a tuple of (tree, options, parent_tree).  The 'options' parameter are the options that
1049         we want to force for 'self'.
1050         """
1051         if not 'dependencies' in self.cscript:
1052             return
1053
1054         if len(inspect.getfullargspec(self.cscript['dependencies']).args) == 2:
1055             self_options = copy.copy(options)
1056             self.add_defaults(self_options)
1057             deps = self.call('dependencies', self_options)
1058         else:
1059             log_normal("Deprecated cscript dependencies() method with no options parameter")
1060             deps = self.call('dependencies')
1061
1062         # Loop over our immediate dependencies
1063         for d in deps:
1064             dep = globals.trees.get(d[0], d[1], self.target, self.name)
1065
1066             # deps only get their options from the parent's cscript
1067             dep_options = d[2] if len(d) > 2 else {}
1068             for i in dep.dependencies(dep_options):
1069                 yield i
1070             yield (dep, dep_options, self)
1071
1072     def checkout_dependencies(self, options={}):
1073         for i in self.dependencies(options):
1074             pass
1075
1076     def build_dependencies(self, options):
1077         """
1078         Called on the 'main' project tree (-p on the command line) to build all dependencies.
1079         'options' will be the ones from the command line.
1080         """
1081         for i in self.dependencies(options):
1082             i[0].build(i[1])
1083
1084     def build(self, options):
1085         if self.built:
1086             return
1087
1088         log_verbose("Building %s %s %s with %s" % (self.name, self.specifier, self.version, options))
1089
1090         variables = copy.copy(self.target.variables)
1091
1092         options = copy.copy(options)
1093         self.add_defaults(options)
1094
1095         if not globals.dry_run:
1096             if len(inspect.getfullargspec(self.cscript['build']).args) == 2:
1097                 self.call('build', options)
1098             else:
1099                 self.call('build')
1100
1101         self.target.variables = variables
1102         self.built = True
1103
1104
1105 #
1106 # Command-line parser
1107 #
1108
1109 def main():
1110
1111     commands = {
1112         "build": "build project",
1113         "package": "build and package the project",
1114         "release": "release a project using its next version number (adding a tag)",
1115         "pot": "build the project's .pot files",
1116         "manual": "build the project's manual",
1117         "doxygen": "build the project's Doxygen documentation",
1118         "latest": "print out the latest version",
1119         "test": "build the project and run its unit tests",
1120         "shell": "start a shell in the project''s work directory",
1121         "checkout": "check out the project",
1122         "revision": "print the head git revision number",
1123         "dependencies" : "print details of the project's dependencies as a .dot file"
1124     }
1125
1126     one_of = ""
1127     summary = ""
1128     for k, v in commands.items():
1129         one_of += "\t%s%s\n" % (k.ljust(20), v)
1130         summary += k + " "
1131
1132     parser = argparse.ArgumentParser()
1133     parser.add_argument('-p', '--project', help='project name')
1134     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
1135     parser.add_argument('-o', '--output', help='output directory', default='.')
1136     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
1137     parser.add_argument('-t', '--target', help='target', action='append')
1138     parser.add_argument('--environment-version', help='version of environment to use')
1139     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
1140     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
1141     parser.add_argument('-w', '--work', help='override default work directory')
1142     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
1143     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
1144     parser.add_argument('-e', '--environment', help='pass the value of the named environment variable into the build', action='append')
1145     parser.add_argument('-m', '--mount', help='mount a given directory in the build environment', action='append')
1146     parser.add_argument('--option', help='set an option for the build (use --option key:value)', action='append')
1147     parser.add_argument('--ccache', help='use ccache', action='store_true')
1148     parser.add_argument('--verbose', help='be verbose', action='store_true')
1149
1150     subparsers = parser.add_subparsers(help='command to run', dest='command')
1151     parser_build = subparsers.add_parser("build", help="build project")
1152     parser_package = subparsers.add_parser("package", help="build and package project")
1153     parser_package.add_argument('--no-notarize', help='do not notarize .dmg packages', action='store_true')
1154     parser_release = subparsers.add_parser("release", help="release a project using its next version number (adding a tag)")
1155     parser_release.add_argument('--minor', help='minor version number bump', action='store_true')
1156     parser_release.add_argument('--micro', help='micro version number bump', action='store_true')
1157     parser_pot = subparsers.add_parser("pot", help="build the project's .pot files")
1158     parser_manual = subparsers.add_parser("manual", help="build the project's manual")
1159     parser_doxygen = subparsers.add_parser("doxygen", help="build the project's Doxygen documentation")
1160     parser_latest = subparsers.add_parser("latest", help="print out the latest version")
1161     parser_latest.add_argument('--major', help='major version to return', type=int)
1162     parser_latest.add_argument('--minor', help='minor version to return', type=int)
1163     parser_test = subparsers.add_parser("test", help="build the project and run its unit tests")
1164     parser_test.add_argument('--no-implicit-build', help='do not build first', action='store_true')
1165     parser_test.add_argument('--test', help="name of test to run, defaults to all")
1166     parser_shell = subparsers.add_parser("shell", help="build the project then start a shell")
1167     parser_checkout = subparsers.add_parser("checkout", help="check out the project")
1168     parser_revision = subparsers.add_parser("revision", help="print the head git revision number")
1169     parser_dependencies = subparsers.add_parser("dependencies", help="print details of the project's dependencies as a .dot file")
1170
1171     global args
1172     args = parser.parse_args()
1173
1174     # Check for incorrect multiple parameters
1175     if args.target is not None:
1176         if len(args.target) > 1:
1177             parser.error('multiple -t options specified')
1178             sys.exit(1)
1179         else:
1180             args.target = args.target[0]
1181
1182     # Override configured stuff
1183     if args.git_prefix is not None:
1184         config.set('git_prefix', args.git_prefix)
1185
1186     if args.output.find(':') == -1:
1187         # This isn't of the form host:path so make it absolute
1188         args.output = os.path.abspath(args.output) + '/'
1189     else:
1190         if args.output[-1] != ':' and args.output[-1] != '/':
1191             args.output += '/'
1192
1193     # Now, args.output is 'host:', 'host:path/' or 'path/'
1194
1195     if args.work is not None:
1196         args.work = os.path.abspath(args.work)
1197         if not os.path.exists(args.work):
1198             os.makedirs(args.work)
1199
1200     if args.project is None and args.command != 'shell':
1201         raise Error('you must specify -p or --project')
1202
1203     globals.quiet = args.quiet
1204     globals.verbose = args.verbose
1205     globals.dry_run = args.dry_run
1206
1207     if args.command == 'build':
1208         if args.target is None:
1209             raise Error('you must specify -t or --target')
1210
1211         target = target_factory(args)
1212         target.build(args.project, args.checkout, get_command_line_options(args))
1213         if not args.keep:
1214             target.cleanup()
1215
1216     elif args.command == 'package':
1217         if args.target is None:
1218             raise Error('you must specify -t or --target')
1219
1220         target = None
1221         try:
1222             target = target_factory(args)
1223
1224             if target.platform == 'linux' and target.detail != "appimage":
1225                 if target.distro != 'arch':
1226                     output_dir = os.path.join(args.output, '%s-%s-%d' % (target.distro, target.version, target.bits))
1227                 else:
1228                     output_dir = os.path.join(args.output, '%s-%d' % (target.distro, target.bits))
1229             else:
1230                 output_dir = args.output
1231
1232             makedirs(output_dir)
1233             target.package(args.project, args.checkout, output_dir, get_command_line_options(args), args.no_notarize)
1234         except Error as e:
1235             if target is not None and not args.keep:
1236                 target.cleanup()
1237             raise
1238
1239         if target is not None and not args.keep:
1240             target.cleanup()
1241
1242     elif args.command == 'release':
1243         if args.minor is False and args.micro is False:
1244             raise Error('you must specify --minor or --micro')
1245
1246         target = SourceTarget()
1247         tree = globals.trees.get(args.project, args.checkout, target)
1248
1249         version = tree.version
1250         version.to_release()
1251         if args.minor:
1252             version.bump_minor()
1253         else:
1254             version.bump_micro()
1255
1256         with TreeDirectory(tree):
1257             command('git tag -m "v%s" v%s' % (version, version))
1258             command('git push --tags')
1259
1260         target.cleanup()
1261
1262     elif args.command == 'pot':
1263         target = SourceTarget()
1264         tree = globals.trees.get(args.project, args.checkout, target)
1265
1266         pots = tree.call('make_pot')
1267         for p in pots:
1268             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
1269
1270         target.cleanup()
1271
1272     elif args.command == 'manual':
1273         target = SourceTarget()
1274         tree = globals.trees.get(args.project, args.checkout, target)
1275
1276         outs = tree.call('make_manual')
1277         for o in outs:
1278             if os.path.isfile(o):
1279                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
1280             else:
1281                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
1282
1283         target.cleanup()
1284
1285     elif args.command == 'doxygen':
1286         target = SourceTarget()
1287         tree = globals.trees.get(args.project, args.checkout, target)
1288
1289         dirs = tree.call('make_doxygen')
1290         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
1291             dirs = [dirs]
1292
1293         for d in dirs:
1294             copytree(d, args.output)
1295
1296         target.cleanup()
1297
1298     elif args.command == 'latest':
1299         target = SourceTarget()
1300         tree = globals.trees.get(args.project, args.checkout, target)
1301
1302         with TreeDirectory(tree):
1303             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
1304             latest = None
1305             line = 0
1306             while latest is None:
1307                 t = f[line]
1308                 line += 1
1309                 m = re.compile(".*\((.*)\).*").match(t)
1310                 if m:
1311                     tags = m.group(1).split(', ')
1312                     for t in tags:
1313                         s = t.split()
1314                         if len(s) > 1:
1315                             t = s[1]
1316                         if len(t) > 0 and t[0] == 'v':
1317                             v = Version(t[1:])
1318                             if (args.major is None or v.major == args.major) and (args.minor is None or v.minor == args.minor):
1319                                 latest = v
1320
1321         print(latest)
1322         target.cleanup()
1323
1324     elif args.command == 'test':
1325         if args.target is None:
1326             raise Error('you must specify -t or --target')
1327
1328         target = None
1329         try:
1330             target = target_factory(args)
1331             options = get_command_line_options(args)
1332             if args.no_implicit_build:
1333                 globals.trees.add_built(args.project, args.checkout, target)
1334             else:
1335                 target.build(args.project, args.checkout, options)
1336             target.test(args.project, args.checkout, target, args.test, options)
1337         finally:
1338             if target is not None and not args.keep:
1339                 target.cleanup()
1340
1341     elif args.command == 'shell':
1342         if args.target is None:
1343             raise Error('you must specify -t or --target')
1344
1345         target = target_factory(args)
1346         target.command('bash')
1347
1348     elif args.command == 'revision':
1349
1350         target = SourceTarget()
1351         tree = globals.trees.get(args.project, args.checkout, target)
1352         with TreeDirectory(tree):
1353             print(command_and_read('git rev-parse HEAD')[0].strip()[:7])
1354         target.cleanup()
1355
1356     elif args.command == 'checkout':
1357
1358         if args.output is None:
1359             raise Error('you must specify -o or --output')
1360
1361         target = SourceTarget()
1362         tree = globals.trees.get(args.project, args.checkout, target)
1363         with TreeDirectory(tree):
1364             shutil.copytree('.', args.output)
1365         target.cleanup()
1366
1367     elif args.command == 'dependencies':
1368         if args.target is None:
1369             raise Error('you must specify -t or --target')
1370         if args.checkout is None:
1371             raise Error('you must specify -c or --checkout')
1372
1373         target = target_factory(args)
1374         tree = globals.trees.get(args.project, args.checkout, target)
1375         print("strict digraph {")
1376         for d in list(tree.dependencies({})):
1377             print("%s -> %s;" % (d[2].name.replace("-", "-"), d[0].name.replace("-", "_")))
1378         print("}")
1379
1380
1381 try:
1382     main()
1383 except Error as e:
1384     print('cdist: %s' % str(e), file=sys.stderr)
1385     sys.exit(1)