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