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