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