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