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