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