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