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