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