0024b65def8499096ce3ffa36ef57a6123c69fcd
[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 _cscript_package(self, tree, options):
429         """
430         Call package() in the cscript and return what it returns, except that
431         anything not in a list will be put into one.
432         """
433         if len(inspect.getfullargspec(tree.cscript['package']).args) == 3:
434             packages = tree.call('package', tree.version, options)
435         else:
436             log_normal("Deprecated cscript package() method with no options parameter")
437             packages = tree.call('package', tree.version)
438
439         return packages if isinstance(packages, list) else [packages]
440
441     def _copy_packages(self, tree, packages, output_dir):
442         for p in packages:
443             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
444
445     def package(self, project, checkout, output_dir, options, notarize):
446         tree = self.build(project, checkout, options)
447         tree.add_defaults(options)
448         p = self._cscript_package(tree, options)
449         self._copy_packages(tree, p, output_dir)
450
451     def build(self, project, checkout, options):
452         tree = globals.trees.get(project, checkout, self)
453         if self.build_dependencies:
454             tree.build_dependencies(options)
455         tree.build(options)
456         return tree
457
458     def test(self, project, checkout, target, test, options):
459         """test is the test case to run, or None"""
460         tree = globals.trees.get(project, checkout, target)
461
462         tree.add_defaults(options)
463         with TreeDirectory(tree):
464             if len(inspect.getfullargspec(tree.cscript['test']).args) == 3:
465                 return tree.call('test', options, test)
466             else:
467                 log_normal('Deprecated cscript test() method with no options parameter')
468                 return tree.call('test', test)
469
470     def set(self, a, b):
471         self.variables[a] = b
472
473     def unset(self, a):
474         del(self.variables[a])
475
476     def get(self, a):
477         return self.variables[a]
478
479     def append(self, k, v, s):
480         if (not k in self.variables) or len(self.variables[k]) == 0:
481             self.variables[k] = '"%s"' % v
482         else:
483             e = self.variables[k]
484             if e[0] == '"' and e[-1] == '"':
485                 self.variables[k] = '"%s%s%s"' % (e[1:-1], s, v)
486             else:
487                 self.variables[k] = '"%s%s%s"' % (e, s, v)
488
489     def append_with_space(self, k, v):
490         return self.append(k, v, ' ')
491
492     def append_with_colon(self, k, v):
493         return self.append(k, v, ':')
494
495     def variables_string(self, escaped_quotes=False):
496         e = ''
497         for k, v in self.variables.items():
498             if escaped_quotes:
499                 v = v.replace('"', '\\"')
500             e += '%s=%s ' % (k, v)
501         return e
502
503     def cleanup(self):
504         if self.rmdir:
505             rmtree(self.directory)
506
507     def mount(self, m):
508         pass
509
510     @property
511     def ccache(self):
512         return self._ccache
513
514     @ccache.setter
515     def ccache(self, v):
516         self._ccache = v
517
518
519 class DockerTarget(Target):
520     def __init__(self, platform, directory):
521         super(DockerTarget, self).__init__(platform, directory)
522         self.mounts = []
523         self.privileged = False
524
525     def _user_tag(self):
526         if config.get('docker_no_user'):
527             return ''
528         return '-u %s' % getpass.getuser()
529
530     def _mount_option(self, d):
531         return '-v %s:%s ' % (os.path.realpath(d), os.path.realpath(d))
532
533     def setup(self):
534         opts = self._mount_option(self.directory)
535         for m in self.mounts:
536             opts += self._mount_option(m)
537         if config.has('git_reference'):
538             opts += self._mount_option(config.get('git_reference'))
539         if self.privileged:
540             opts += '--privileged=true '
541         if self.ccache:
542             opts += "-e CCACHE_DIR=/ccache/%s-%d --mount source=ccache,target=/ccache" % (self.image, os.getuid())
543
544         tag = self.image
545         if config.has('docker_hub_repository'):
546             tag = '%s:%s' % (config.get('docker_hub_repository'), tag)
547
548         self.container = command_and_read('%s run %s %s -itd %s /bin/bash' % (config.docker(), self._user_tag(), opts, tag))[0].strip()
549
550     def command(self, cmd):
551         dir = os.path.join(self.directory, os.path.relpath(os.getcwd(), self.directory))
552         interactive_flag = '-i ' if sys.stdin.isatty() else ''
553         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))
554
555     def cleanup(self):
556         super(DockerTarget, self).cleanup()
557         command('%s kill %s' % (config.docker(), self.container))
558
559     def mount(self, m):
560         self.mounts.append(m)
561
562
563 class FlatpakTarget(Target):
564     def __init__(self, project, checkout):
565         super(FlatpakTarget, self).__init__('flatpak')
566         self.build_dependencies = False
567         self.project = project
568         self.checkout = checkout
569
570     def setup(self):
571         pass
572
573     def command(self, cmd):
574         command(cmd)
575
576     def checkout_dependencies(self):
577         tree = globals.trees.get(self.project, self.checkout, self)
578         return tree.checkout_dependencies()
579
580     def flatpak(self):
581         return 'flatpak'
582
583     def flatpak_builder(self):
584         b = 'flatpak-builder'
585         if config.has('flatpak_state_dir'):
586             b += ' --state-dir=%s' % config.get('flatpak_state_dir')
587         return b
588
589
590 class WindowsDockerTarget(DockerTarget):
591     """
592     This target exposes the following additional API:
593
594     version: Windows version ('xp' or None)
595     bits: bitness of Windows (32 or 64)
596     name: name of our target e.g. x86_64-w64-mingw32.shared
597     environment_prefix: path to Windows environment for the appropriate target (libraries and some tools)
598     tool_path: path to 32- and 64-bit tools
599     """
600     def __init__(self, windows_version, bits, directory, environment_version):
601         super(WindowsDockerTarget, self).__init__('windows', directory)
602         self.version = windows_version
603         self.bits = bits
604
605         self.tool_path = '%s/usr/bin' % config.get('mxe_prefix')
606         if self.bits == 32:
607             self.name = 'i686-w64-mingw32.shared'
608         else:
609             self.name = 'x86_64-w64-mingw32.shared'
610         self.environment_prefix = '%s/usr/%s' % (config.get('mxe_prefix'), self.name)
611
612         self.set('PKG_CONFIG_LIBDIR', '%s/lib/pkgconfig' % self.environment_prefix)
613         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
614         self.set('PATH', '%s/bin:%s:%s' % (self.environment_prefix, self.tool_path, os.environ['PATH']))
615         self.set('LD', '%s-ld' % self.name)
616         self.set('RANLIB', '%s-ranlib' % self.name)
617         self.set('WINRC', '%s-windres' % self.name)
618         cxx = '-I%s/include -I%s/include' % (self.environment_prefix, self.directory)
619         link = '-L%s/lib -L%s/lib' % (self.environment_prefix, self.directory)
620         self.set('CXXFLAGS', '"%s"' % cxx)
621         self.set('CPPFLAGS', '')
622         self.set('LINKFLAGS', '"%s"' % link)
623         self.set('LDFLAGS', '"%s"' % link)
624
625         self.image = 'windows'
626         if environment_version is not None:
627             self.image += '_%s' % environment_version
628
629     def setup(self):
630         super().setup()
631         if self.ccache:
632             self.set('CC', '"ccache %s-gcc"' % self.name)
633             self.set('CXX', '"ccache %s-g++"' % self.name)
634         else:
635             self.set('CC', '%s-gcc' % self.name)
636             self.set('CXX', '%s-g++' % self.name)
637
638     @property
639     def library_prefix(self):
640         log_normal('Deprecated property library_prefix: use environment_prefix')
641         return self.environment_prefix
642
643     @property
644     def windows_prefix(self):
645         log_normal('Deprecated property windows_prefix: use environment_prefix')
646         return self.environment_prefix
647
648     @property
649     def mingw_prefixes(self):
650         log_normal('Deprecated property mingw_prefixes: use environment_prefix')
651         return [self.environment_prefix]
652
653     @property
654     def mingw_path(self):
655         log_normal('Deprecated property mingw_path: use tool_path')
656         return self.tool_path
657
658     @property
659     def mingw_name(self):
660         log_normal('Deprecated property mingw_name: use name')
661         return self.name
662
663
664 class WindowsNativeTarget(Target):
665     """
666     This target exposes the following additional API:
667
668     version: Windows version ('xp' or None)
669     bits: bitness of Windows (32 or 64)
670     name: name of our target e.g. x86_64-w64-mingw32.shared
671     environment_prefix: path to Windows environment for the appropriate target (libraries and some tools)
672     """
673     def __init__(self, directory):
674         super().__init__('windows', directory)
675         self.version = None
676         self.bits = 64
677
678         self.environment_prefix = config.get('windows_native_environmnet_prefix')
679
680         self.set('PATH', '%s/bin:%s' % (self.environment_prefix, os.environ['PATH']))
681
682     def command(self, cmd):
683         command(cmd)
684
685
686 class LinuxTarget(DockerTarget):
687     """
688     Build for Linux in a docker container.
689     This target exposes the following additional API:
690
691     distro: distribution ('debian', 'ubuntu', 'centos' or 'fedora')
692     version: distribution version (e.g. '12.04', '8', '6.5')
693     bits: bitness of the distribution (32 or 64)
694     detail: None or 'appimage' if we are building for appimage
695     """
696
697     def __init__(self, distro, version, bits, directory=None):
698         super(LinuxTarget, self).__init__('linux', directory)
699         self.distro = distro
700         self.version = version
701         self.bits = bits
702         self.detail = None
703
704         self.set('CXXFLAGS', '-I%s/include' % self.directory)
705         self.set('CPPFLAGS', '')
706         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
707         self.set('PKG_CONFIG_PATH',
708                  '%s/lib/pkgconfig:%s/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/local/lib/pkgconfig' % (self.directory, self.directory))
709         self.set('PATH', '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin')
710
711         if self.version is None:
712             self.image = '%s-%s' % (self.distro, self.bits)
713         else:
714             self.image = '%s-%s-%s' % (self.distro, self.version, self.bits)
715
716     def setup(self):
717         super(LinuxTarget, self).setup()
718         if self.ccache:
719             self.set('CC', '"ccache gcc"')
720             self.set('CXX', '"ccache g++"')
721
722     def test(self, project, checkout, target, test, options):
723         self.append_with_colon('PATH', '%s/bin' % self.directory)
724         self.append_with_colon('LD_LIBRARY_PATH', '%s/lib' % self.directory)
725         super(LinuxTarget, self).test(project, checkout, target, test, options)
726
727
728 class AppImageTarget(LinuxTarget):
729     def __init__(self, work):
730         super(AppImageTarget, self).__init__('ubuntu', '18.04', 64, work)
731         self.detail = 'appimage'
732         self.privileged = True
733
734
735 def notarize_dmg(dmg, bundle_id):
736     p = subprocess.run(
737         ['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'],
738         capture_output=True
739         )
740
741     def string_after(process, key):
742         lines = p.stdout.decode('utf-8').splitlines()
743         for i in range(0, len(lines)):
744             if lines[i].find(key) != -1:
745                 return lines[i+1].strip().replace('<string>', '').replace('</string>', '')
746
747     request_uuid = string_after(p, "RequestUUID")
748     if request_uuid is None:
749         raise Error('No RequestUUID found in response from Apple')
750
751     for i in range(0, 30):
752         print('Checking up on %s' % request_uuid)
753         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)
754         status = string_after(p, 'Status')
755         print('Got %s' % status)
756         if status == 'invalid':
757             raise Error("Notarization failed")
758         elif status == 'success':
759             subprocess.run(['xcrun', 'stapler', 'staple', dmg])
760             return
761         elif status != "in progress":
762             print("Could not understand xcrun response")
763             print(p)
764         time.sleep(30)
765
766     raise Error("Notarization timed out")
767
768
769 class OSXTarget(Target):
770     def __init__(self, directory=None):
771         super(OSXTarget, self).__init__('osx', directory)
772         self.sdk_prefix = config.get('osx_sdk_prefix')
773         self.environment_prefix = config.get('osx_environment_prefix')
774         self.apple_id = config.get('apple_id')
775         self.apple_password = config.get('apple_password')
776         self.osx_keychain_file = config.get('osx_keychain_file')
777         self.osx_keychain_password = config.get('osx_keychain_password')
778
779     def command(self, c):
780         command('%s %s' % (self.variables_string(False), c))
781
782     def unlock_keychain(self):
783         self.command('security unlock-keychain -p %s %s' % (self.osx_keychain_password, self.osx_keychain_file))
784
785     def _cscript_package_and_notarize(self, tree, options, notarize):
786         """
787         Call package() in the cscript and notarize the .dmgs that are returned, if notarize = True
788         """
789         p = self._cscript_package(tree, options)
790         for x in p:
791             if not isinstance(x, tuple):
792                 raise Error('macOS packages must be returned from cscript as tuples of (dmg-filename, bundle-id)')
793             if notarize:
794                 notarize_dmg(x[0], x[1])
795         return [x[0] for x in p]
796
797
798 class OSXSingleTarget(OSXTarget):
799     def __init__(self, arch, sdk, directory=None):
800         super(OSXSingleTarget, self).__init__(directory)
801         self.arch = arch
802         self.sdk = sdk
803
804         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (self.sdk_prefix, sdk, arch)
805         host_enviro = '%s/x86_64/10.9' % config.get('osx_environment_prefix')
806         target_enviro = '%s/%s/%s' % (config.get('osx_environment_prefix'), arch, sdk)
807
808         self.bin = '%s/bin' % target_enviro
809
810         # Environment variables
811         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, target_enviro, flags))
812         self.set('CPPFLAGS', '')
813         self.set('CXXFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, target_enviro, flags))
814         self.set('LDFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, target_enviro, flags))
815         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, target_enviro, flags))
816         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, target_enviro))
817         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % host_enviro)
818         self.set('MACOSX_DEPLOYMENT_TARGET', sdk)
819         self.set('CCACHE_BASEDIR', self.directory)
820
821     @Target.ccache.setter
822     def ccache(self, v):
823         Target.ccache.fset(self, v)
824         if v:
825             self.set('CC', '"ccache gcc"')
826             self.set('CXX', '"ccache g++"')
827
828     def package(self, project, checkout, output_dir, options, notarize):
829         tree = self.build(project, checkout, options)
830         tree.add_defaults(options)
831         self.unlock_keychain()
832         p = self._cscript_package_and_notarize(tree, options, notarize)
833         self._copy_packages(tree, p, output_dir)
834
835
836 class OSXUniversalTarget(OSXTarget):
837     def __init__(self, archs, directory=None):
838         super(OSXUniversalTarget, self).__init__(directory)
839         self.archs = archs
840         self.sdk = config.get('osx_sdk')
841         for a in self.archs:
842             if a.find('arm') != -1:
843                 self.sdk = '11.0'
844
845     def package(self, project, checkout, output_dir, options, notarize):
846         for a in self.archs:
847             target = OSXSingleTarget(a, self.sdk, os.path.join(self.directory, a))
848             target.ccache = self.ccache
849             tree = globals.trees.get(project, checkout, target)
850             tree.build_dependencies(options)
851             tree.build(options)
852
853         self.unlock_keychain()
854         tree = globals.trees.get(project, checkout, self)
855         with TreeDirectory(tree):
856             for p in self._cscript_package_and_notarize(tree, options, notarize):
857                 copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
858
859 class SourceTarget(Target):
860     """Build a source .tar.bz2"""
861     def __init__(self):
862         super(SourceTarget, self).__init__('source')
863
864     def command(self, c):
865         log_normal('host -> %s' % c)
866         command('%s %s' % (self.variables_string(), c))
867
868     def cleanup(self):
869         rmtree(self.directory)
870
871     def package(self, project, checkout, output_dir, options, notarize):
872         tree = globals.trees.get(project, checkout, self)
873         with TreeDirectory(tree):
874             name = read_wscript_variable(os.getcwd(), 'APPNAME')
875             command('./waf dist')
876             p = os.path.abspath('%s-%s.tar.bz2' % (name, tree.version))
877             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
878
879 # @param s Target string:
880 #       windows-{32,64}
881 #    or ubuntu-version-{32,64}
882 #    or debian-version-{32,64}
883 #    or centos-version-{32,64}
884 #    or fedora-version-{32,64}
885 #    or mageia-version-{32,64}
886 #    or osx-{intel,arm}
887 #    or source
888 #    or flatpak
889 #    or appimage
890 # @param debug True to build with debugging symbols (where possible)
891 def target_factory(args):
892     s = args.target
893     target = None
894     if s.startswith('windows-'):
895         x = s.split('-')
896         if platform.system() == "Windows":
897             target = WindowsNativeTarget(args.work)
898         else:
899             if len(x) == 2:
900                 target = WindowsDockerTarget(None, int(x[1]), args.work, args.environment_version)
901             elif len(x) == 3:
902                 target = WindowsDockerTarget(x[1], int(x[2]), args.work, args.environment_version)
903             else:
904                 raise Error("Bad Windows target name `%s'")
905     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-') or s.startswith('fedora-') or s.startswith('mageia-'):
906         p = s.split('-')
907         if len(p) != 3:
908             raise Error("Bad Linux target name `%s'; must be something like ubuntu-16.04-32 (i.e. distro-version-bits)" % s)
909         target = LinuxTarget(p[0], p[1], int(p[2]), args.work)
910     elif s.startswith('arch-'):
911         p = s.split('-')
912         if len(p) != 2:
913             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
914         target = LinuxTarget(p[0], None, int(p[1]), args.work)
915     elif s == 'raspbian':
916         target = LinuxTarget(s, None, None, args.work)
917     elif s == 'osx-intel-32-64':
918         # Universal Intel 32/64-bit built for config's os_sdk
919         if args.command == 'build':
920             target = OSXSingleTarget('x86_64', args.work)
921         else:
922             target = OSXUniversalTarget(('i386', 'x86_64'), args.work)
923     elif s == 'osx-arm-intel-64':
924         # Universal arm64 and Intel 64-bit built for SDK 11.0
925         target = OSXUniversalTarget(('arm64', 'x86_64'), args.work)
926     elif s == 'osx-arm64':
927         target = OSXSingleTarget('arm64', '11.0', args.work)
928     elif s == 'source':
929         target = SourceTarget()
930     elif s == 'flatpak':
931         target = FlatpakTarget(args.project, args.checkout)
932     elif s == 'appimage':
933         target = AppImageTarget(args.work)
934
935     if target is None:
936         raise Error("Bad target `%s'" % s)
937
938     target.debug = args.debug
939     target.ccache = args.ccache
940
941     if args.environment is not None:
942         for e in args.environment:
943             target.set(e, os.environ[e])
944
945     if args.mount is not None:
946         for m in args.mount:
947             target.mount(m)
948
949     target.setup()
950     return target
951
952
953 #
954 # Tree
955 #
956
957 class Tree(object):
958     """Description of a tree, which is a checkout of a project,
959        possibly built.  This class is never exposed to cscripts.
960        Attributes:
961            name -- name of git repository (without the .git)
962            specifier -- git tag or revision to use
963            target -- target object that we are using
964            version -- version from the wscript (if one is present)
965            git_commit -- git revision that is actually being used
966            built -- true if the tree has been built yet in this run
967            required_by -- name of the tree that requires this one
968     """
969
970     def __init__(self, name, specifier, target, required_by, built=False):
971         self.name = name
972         self.specifier = specifier
973         self.target = target
974         self.version = None
975         self.git_commit = None
976         self.built = built
977         self.required_by = required_by
978
979         cwd = os.getcwd()
980         proj = '%s/src/%s' % (target.directory, self.name)
981
982         if not built:
983             flags = ''
984             redirect = ''
985             if globals.quiet:
986                 flags = '-q'
987                 redirect = '>/dev/null'
988             if config.has('git_reference'):
989                 ref = '--reference-if-able %s/%s.git' % (config.get('git_reference'), self.name)
990             else:
991                 ref = ''
992             command('git clone %s %s %s/%s.git %s/src/%s' % (flags, ref, config.get('git_prefix'), self.name, target.directory, self.name))
993             os.chdir('%s/src/%s' % (target.directory, self.name))
994
995             spec = self.specifier
996             if spec is None:
997                 spec = 'master'
998
999             command('git checkout %s %s %s' % (flags, spec, redirect))
1000             self.git_commit = command_and_read('git rev-parse --short=7 HEAD')[0].strip()
1001
1002         self.cscript = {}
1003         exec(open('%s/cscript' % proj).read(), self.cscript)
1004
1005         if not built:
1006             # cscript can include submodules = False to stop submodules being fetched
1007             if (not 'submodules' in self.cscript or self.cscript['submodules'] == True) and os.path.exists('.gitmodules'):
1008                 command('git submodule --quiet init')
1009                 paths = command_and_read('git config --file .gitmodules --get-regexp path')
1010                 urls = command_and_read('git config --file .gitmodules --get-regexp url')
1011                 for path, url in zip(paths, urls):
1012                     ref = ''
1013                     if config.has('git_reference'):
1014                         url = url.split(' ')[1]
1015                         ref_path = os.path.join(config.get('git_reference'), os.path.basename(url))
1016                         if os.path.exists(ref_path):
1017                             ref = '--reference %s' % ref_path
1018                     path = path.split(' ')[1]
1019                     command('git submodule --quiet update %s %s' % (ref, path))
1020
1021         if os.path.exists('%s/wscript' % proj):
1022             v = read_wscript_variable(proj, "VERSION");
1023             if v is not None:
1024                 try:
1025                     self.version = Version(v)
1026                 except:
1027                     try:
1028                         tag = command_and_read('git -C %s describe --tags' % proj)[0][1:]
1029                         self.version = Version.from_git_tag(tag)
1030                     except:
1031                         # We'll leave version as None if we can't read it; maybe this is a bad idea
1032                         # Should probably just install git on the Windows VM
1033                         pass
1034
1035         os.chdir(cwd)
1036
1037     def call(self, function, *args):
1038         with TreeDirectory(self):
1039             return self.cscript[function](self.target, *args)
1040
1041     def add_defaults(self, options):
1042         """Add the defaults from self into a dict options"""
1043         if 'option_defaults' in self.cscript:
1044             from_cscript = self.cscript['option_defaults']
1045             if isinstance(from_cscript, dict):
1046                 defaults_dict = from_cscript
1047             else:
1048                 log_normal("Deprecated cscript option_defaults method; replace with a dict")
1049                 defaults_dict = from_cscript()
1050             for k, v in defaults_dict.items():
1051                 if not k in options:
1052                     options[k] = v
1053
1054     def dependencies(self, options):
1055         """
1056         yield details of the dependencies of this tree.  Each dependency is returned
1057         as a tuple of (tree, options, parent_tree).  The 'options' parameter are the options that
1058         we want to force for 'self'.
1059         """
1060         if not 'dependencies' in self.cscript:
1061             return
1062
1063         if len(inspect.getfullargspec(self.cscript['dependencies']).args) == 2:
1064             self_options = copy.copy(options)
1065             self.add_defaults(self_options)
1066             deps = self.call('dependencies', self_options)
1067         else:
1068             log_normal("Deprecated cscript dependencies() method with no options parameter")
1069             deps = self.call('dependencies')
1070
1071         # Loop over our immediate dependencies
1072         for d in deps:
1073             dep = globals.trees.get(d[0], d[1], self.target, self.name)
1074
1075             # deps only get their options from the parent's cscript
1076             dep_options = d[2] if len(d) > 2 else {}
1077             for i in dep.dependencies(dep_options):
1078                 yield i
1079             yield (dep, dep_options, self)
1080
1081     def checkout_dependencies(self, options={}):
1082         for i in self.dependencies(options):
1083             pass
1084
1085     def build_dependencies(self, options):
1086         """
1087         Called on the 'main' project tree (-p on the command line) to build all dependencies.
1088         'options' will be the ones from the command line.
1089         """
1090         for i in self.dependencies(options):
1091             i[0].build(i[1])
1092
1093     def build(self, options):
1094         if self.built:
1095             return
1096
1097         log_verbose("Building %s %s %s with %s" % (self.name, self.specifier, self.version, options))
1098
1099         variables = copy.copy(self.target.variables)
1100
1101         options = copy.copy(options)
1102         self.add_defaults(options)
1103
1104         if not globals.dry_run:
1105             if len(inspect.getfullargspec(self.cscript['build']).args) == 2:
1106                 self.call('build', options)
1107             else:
1108                 self.call('build')
1109
1110         self.target.variables = variables
1111         self.built = True
1112
1113
1114 #
1115 # Command-line parser
1116 #
1117
1118 def main():
1119
1120     commands = {
1121         "build": "build project",
1122         "package": "build and package the project",
1123         "release": "release a project using its next version number (adding a tag)",
1124         "pot": "build the project's .pot files",
1125         "manual": "build the project's manual",
1126         "doxygen": "build the project's Doxygen documentation",
1127         "latest": "print out the latest version",
1128         "test": "build the project and run its unit tests",
1129         "shell": "start a shell in the project''s work directory",
1130         "checkout": "check out the project",
1131         "revision": "print the head git revision number",
1132         "dependencies" : "print details of the project's dependencies as a .dot file"
1133     }
1134
1135     one_of = ""
1136     summary = ""
1137     for k, v in commands.items():
1138         one_of += "\t%s%s\n" % (k.ljust(20), v)
1139         summary += k + " "
1140
1141     parser = argparse.ArgumentParser()
1142     parser.add_argument('-p', '--project', help='project name')
1143     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
1144     parser.add_argument('-o', '--output', help='output directory', default='.')
1145     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
1146     parser.add_argument('-t', '--target', help='target', action='append')
1147     parser.add_argument('--environment-version', help='version of environment to use')
1148     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
1149     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
1150     parser.add_argument('-w', '--work', help='override default work directory')
1151     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
1152     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
1153     parser.add_argument('-e', '--environment', help='pass the value of the named environment variable into the build', action='append')
1154     parser.add_argument('-m', '--mount', help='mount a given directory in the build environment', action='append')
1155     parser.add_argument('--option', help='set an option for the build (use --option key:value)', action='append')
1156     parser.add_argument('--ccache', help='use ccache', action='store_true')
1157     parser.add_argument('--verbose', help='be verbose', action='store_true')
1158
1159     subparsers = parser.add_subparsers(help='command to run', dest='command')
1160     parser_build = subparsers.add_parser("build", help="build project")
1161     parser_package = subparsers.add_parser("package", help="build and package project")
1162     parser_package.add_argument('--no-notarize', help='do not notarize .dmg packages', action='store_true')
1163     parser_release = subparsers.add_parser("release", help="release a project using its next version number (adding a tag)")
1164     parser_release.add_argument('--minor', help='minor version number bump', action='store_true')
1165     parser_release.add_argument('--micro', help='micro version number bump', action='store_true')
1166     parser_pot = subparsers.add_parser("pot", help="build the project's .pot files")
1167     parser_manual = subparsers.add_parser("manual", help="build the project's manual")
1168     parser_doxygen = subparsers.add_parser("doxygen", help="build the project's Doxygen documentation")
1169     parser_latest = subparsers.add_parser("latest", help="print out the latest version")
1170     parser_latest.add_argument('--major', help='major version to return', type=int)
1171     parser_latest.add_argument('--minor', help='minor version to return', type=int)
1172     parser_test = subparsers.add_parser("test", help="build the project and run its unit tests")
1173     parser_test.add_argument('--no-implicit-build', help='do not build first', action='store_true')
1174     parser_test.add_argument('--test', help="name of test to run, defaults to all")
1175     parser_shell = subparsers.add_parser("shell", help="build the project then start a shell")
1176     parser_checkout = subparsers.add_parser("checkout", help="check out the project")
1177     parser_revision = subparsers.add_parser("revision", help="print the head git revision number")
1178     parser_dependencies = subparsers.add_parser("dependencies", help="print details of the project's dependencies as a .dot file")
1179
1180     global args
1181     args = parser.parse_args()
1182
1183     # Check for incorrect multiple parameters
1184     if args.target is not None:
1185         if len(args.target) > 1:
1186             parser.error('multiple -t options specified')
1187             sys.exit(1)
1188         else:
1189             args.target = args.target[0]
1190
1191     # Override configured stuff
1192     if args.git_prefix is not None:
1193         config.set('git_prefix', args.git_prefix)
1194
1195     if args.output.find(':') == -1:
1196         # This isn't of the form host:path so make it absolute
1197         args.output = os.path.abspath(args.output) + '/'
1198     else:
1199         if args.output[-1] != ':' and args.output[-1] != '/':
1200             args.output += '/'
1201
1202     # Now, args.output is 'host:', 'host:path/' or 'path/'
1203
1204     if args.work is not None:
1205         args.work = os.path.abspath(args.work)
1206         if not os.path.exists(args.work):
1207             os.makedirs(args.work)
1208
1209     if args.project is None and args.command != 'shell':
1210         raise Error('you must specify -p or --project')
1211
1212     globals.quiet = args.quiet
1213     globals.verbose = args.verbose
1214     globals.dry_run = args.dry_run
1215
1216     if args.command == 'build':
1217         if args.target is None:
1218             raise Error('you must specify -t or --target')
1219
1220         target = target_factory(args)
1221         target.build(args.project, args.checkout, get_command_line_options(args))
1222         if not args.keep:
1223             target.cleanup()
1224
1225     elif args.command == 'package':
1226         if args.target is None:
1227             raise Error('you must specify -t or --target')
1228
1229         target = None
1230         try:
1231             target = target_factory(args)
1232
1233             if target.platform == 'linux' and target.detail != "appimage":
1234                 if target.distro != 'arch':
1235                     output_dir = os.path.join(args.output, '%s-%s-%d' % (target.distro, target.version, target.bits))
1236                 else:
1237                     output_dir = os.path.join(args.output, '%s-%d' % (target.distro, target.bits))
1238             else:
1239                 output_dir = args.output
1240
1241             makedirs(output_dir)
1242             target.package(args.project, args.checkout, output_dir, get_command_line_options(args), not args.no_notarize)
1243         except Error as e:
1244             if target is not None and not args.keep:
1245                 target.cleanup()
1246             raise
1247
1248         if target is not None and not args.keep:
1249             target.cleanup()
1250
1251     elif args.command == 'release':
1252         if args.minor is False and args.micro is False:
1253             raise Error('you must specify --minor or --micro')
1254
1255         target = SourceTarget()
1256         tree = globals.trees.get(args.project, args.checkout, target)
1257
1258         version = tree.version
1259         version.to_release()
1260         if args.minor:
1261             version.bump_minor()
1262         else:
1263             version.bump_micro()
1264
1265         with TreeDirectory(tree):
1266             command('git tag -m "v%s" v%s' % (version, version))
1267             command('git push --tags')
1268
1269         target.cleanup()
1270
1271     elif args.command == 'pot':
1272         target = SourceTarget()
1273         tree = globals.trees.get(args.project, args.checkout, target)
1274
1275         pots = tree.call('make_pot')
1276         for p in pots:
1277             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
1278
1279         target.cleanup()
1280
1281     elif args.command == 'manual':
1282         target = SourceTarget()
1283         tree = globals.trees.get(args.project, args.checkout, target)
1284
1285         outs = tree.call('make_manual')
1286         for o in outs:
1287             if os.path.isfile(o):
1288                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
1289             else:
1290                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
1291
1292         target.cleanup()
1293
1294     elif args.command == 'doxygen':
1295         target = SourceTarget()
1296         tree = globals.trees.get(args.project, args.checkout, target)
1297
1298         dirs = tree.call('make_doxygen')
1299         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
1300             dirs = [dirs]
1301
1302         for d in dirs:
1303             copytree(d, args.output)
1304
1305         target.cleanup()
1306
1307     elif args.command == 'latest':
1308         target = SourceTarget()
1309         tree = globals.trees.get(args.project, args.checkout, target)
1310
1311         with TreeDirectory(tree):
1312             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
1313             latest = None
1314             line = 0
1315             while latest is None:
1316                 t = f[line]
1317                 line += 1
1318                 m = re.compile(".*\((.*)\).*").match(t)
1319                 if m:
1320                     tags = m.group(1).split(', ')
1321                     for t in tags:
1322                         s = t.split()
1323                         if len(s) > 1:
1324                             t = s[1]
1325                         if len(t) > 0 and t[0] == 'v':
1326                             v = Version(t[1:])
1327                             if (args.major is None or v.major == args.major) and (args.minor is None or v.minor == args.minor):
1328                                 latest = v
1329
1330         print(latest)
1331         target.cleanup()
1332
1333     elif args.command == 'test':
1334         if args.target is None:
1335             raise Error('you must specify -t or --target')
1336
1337         target = None
1338         try:
1339             target = target_factory(args)
1340             options = get_command_line_options(args)
1341             if args.no_implicit_build:
1342                 globals.trees.add_built(args.project, args.checkout, target)
1343             else:
1344                 target.build(args.project, args.checkout, options)
1345             target.test(args.project, args.checkout, target, args.test, options)
1346         finally:
1347             if target is not None and not args.keep:
1348                 target.cleanup()
1349
1350     elif args.command == 'shell':
1351         if args.target is None:
1352             raise Error('you must specify -t or --target')
1353
1354         target = target_factory(args)
1355         target.command('bash')
1356
1357     elif args.command == 'revision':
1358
1359         target = SourceTarget()
1360         tree = globals.trees.get(args.project, args.checkout, target)
1361         with TreeDirectory(tree):
1362             print(command_and_read('git rev-parse HEAD')[0].strip()[:7])
1363         target.cleanup()
1364
1365     elif args.command == 'checkout':
1366
1367         if args.output is None:
1368             raise Error('you must specify -o or --output')
1369
1370         target = SourceTarget()
1371         tree = globals.trees.get(args.project, args.checkout, target)
1372         with TreeDirectory(tree):
1373             shutil.copytree('.', args.output)
1374         target.cleanup()
1375
1376     elif args.command == 'dependencies':
1377         if args.target is None:
1378             raise Error('you must specify -t or --target')
1379         if args.checkout is None:
1380             raise Error('you must specify -c or --checkout')
1381
1382         target = target_factory(args)
1383         tree = globals.trees.get(args.project, args.checkout, target)
1384         print("strict digraph {")
1385         for d in list(tree.dependencies({})):
1386             print("%s -> %s;" % (d[2].name.replace("-", "-"), d[0].name.replace("-", "_")))
1387         print("}")
1388
1389
1390 try:
1391     main()
1392 except Error as e:
1393     print('cdist: %s' % str(e), file=sys.stderr)
1394     sys.exit(1)