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