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