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