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