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