Remove cdistvm; allow docker use with sudo.
[cdist.git] / cdist
1 #!/usr/bin/python
2
3 #    Copyright (C) 2012-2017 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
32 TEMPORARY_DIRECTORY = '/var/tmp'
33
34 class Error(Exception):
35     def __init__(self, value):
36         self.value = value
37     def __str__(self):
38         return self.value
39     def __repr__(self):
40         return str(self)
41
42 class Trees:
43     """
44     Store for Tree objects which re-uses already-created objects
45     and checks for requests for different versions of the same thing.
46     """
47
48     def __init__(self):
49         self.trees = []
50
51     def get(self, name, specifier, target):
52         for t in self.trees:
53             if t.name == name and t.specifier == specifier and t.target == target:
54                 return t
55             elif t.name == name and t.specifier != specifier:
56                 raise Error('conflicting versions of %s requested (%s and %s)' % (name, specifier, t.specifier))
57
58         nt = Tree(name, specifier, target)
59         self.trees.append(nt)
60         return nt
61
62 class Globals:
63     quiet = False
64     command = None
65     dry_run = False
66     trees = Trees()
67
68 globals = Globals()
69
70
71 #
72 # Configuration
73 #
74
75 class Option(object):
76     def __init__(self, key, default=None):
77         self.key = key
78         self.value = default
79
80     def offer(self, key, value):
81         if key == self.key:
82             self.value = value
83
84 class BoolOption(object):
85     def __init__(self, key):
86         self.key = key
87         self.value = False
88
89     def offer(self, key, value):
90         if key == self.key:
91             self.value = (value == 'yes' or value == '1' or value == 'true')
92
93 class Config:
94     def __init__(self):
95         self.options = [ Option('windows_environment_prefix'),
96                          Option('mingw_prefix'),
97                          Option('git_prefix'),
98                          Option('osx_build_host'),
99                          Option('osx_environment_prefix'),
100                          Option('osx_sdk_prefix'),
101                          Option('osx_sdk'),
102                          BoolOption('docker_sudo'),
103                          Option('parallel', 4) ]
104
105         try:
106             f = open('%s/.config/cdist' % os.path.expanduser('~'), 'r')
107             while True:
108                 l = f.readline()
109                 if l == '':
110                     break
111
112                 if len(l) > 0 and l[0] == '#':
113                     continue
114
115                 s = l.strip().split()
116                 if len(s) == 2:
117                     for k in self.options:
118                         k.offer(s[0], s[1])
119         except:
120             raise
121
122     def get(self, k):
123         for o in self.options:
124             if o.key == k:
125                 if o.value is None:
126                     raise Error('Required setting %s not found' % k)
127                 return o.value
128
129     def set(self, k, v):
130         for o in self.options:
131             o.offer(k, v)
132
133     def docker(self):
134         if self.get('docker_sudo'):
135             return 'sudo docker'
136         else:
137             return 'docker'
138
139 config = Config()
140
141 #
142 # Utility bits
143 #
144
145 def log(m):
146     if not globals.quiet:
147         print('\x1b[33m* %s\x1b[0m' % m)
148
149 def scp_escape(n):
150     """Escape a host:filename string for use with an scp command"""
151     s = n.split(':')
152     assert(len(s) == 1 or len(s) == 2)
153     if len(s) == 2:
154         return '%s:"\'%s\'"' % (s[0], s[1])
155     else:
156         return '\"%s\"' % s[0]
157
158 def copytree(a, b):
159     log('copy %s -> %s' % (scp_escape(b), scp_escape(b)))
160     command('scp -r %s %s' % (scp_escape(a), scp_escape(b)))
161
162 def copyfile(a, b):
163     log('copy %s -> %s' % (scp_escape(a), scp_escape(b)))
164     command('scp %s %s' % (scp_escape(a), scp_escape(b)))
165
166 def makedirs(d):
167     """
168     Make directories either locally or on a remote host; remotely if
169     d includes a colon, otherwise locally.
170     """
171     if d.find(':') == -1:
172         try:
173             os.makedirs(d)
174         except OSError as e:
175             if e.errno != 17:
176                 raise e
177     else:
178         s = d.split(':')
179         command('ssh %s -- mkdir -p %s' % (s[0], s[1]))
180
181 def rmdir(a):
182     log('remove %s' % a)
183     os.rmdir(a)
184
185 def rmtree(a):
186     log('remove %s' % a)
187     shutil.rmtree(a, ignore_errors=True)
188
189 def command(c):
190     log(c)
191     r = os.system(c)
192     if (r >> 8):
193         raise Error('command %s failed' % c)
194
195 def command_and_read(c):
196     log(c)
197     p = subprocess.Popen(c.split(), stdout=subprocess.PIPE)
198     f = os.fdopen(os.dup(p.stdout.fileno()))
199     return f
200
201 def read_wscript_variable(directory, variable):
202     f = open('%s/wscript' % directory, 'r')
203     while True:
204         l = f.readline()
205         if l == '':
206             break
207
208         s = l.split()
209         if len(s) == 3 and s[0] == variable:
210             f.close()
211             return s[2][1:-1]
212
213     f.close()
214     return None
215
216 def set_version_in_wscript(version):
217     f = open('wscript', 'rw')
218     o = open('wscript.tmp', 'w')
219     while True:
220         l = f.readline()
221         if l == '':
222             break
223
224         s = l.split()
225         if len(s) == 3 and s[0] == "VERSION":
226             print("Writing %s" % version)
227             print("VERSION = '%s'" % version, file=o)
228         else:
229             print(l, file=o, end="")
230     f.close()
231     o.close()
232
233     os.rename('wscript.tmp', 'wscript')
234
235 def append_version_to_changelog(version):
236     try:
237         f = open('ChangeLog', 'r')
238     except:
239         log('Could not open ChangeLog')
240         return
241
242     c = f.read()
243     f.close()
244
245     f = open('ChangeLog', 'w')
246     now = datetime.datetime.now()
247     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))
248     f.write(c)
249
250 def append_version_to_debian_changelog(version):
251     if not os.path.exists('debian'):
252         log('Could not find debian directory')
253         return
254
255     command('dch -b -v %s-1 "New upstream release."' % version)
256
257 def devel_to_git(git_commit, filename):
258     if git_commit is not None:
259         filename = filename.replace('devel', '-%s' % git_commit)
260     return filename
261
262 class TreeDirectory:
263     def __init__(self, tree):
264         self.tree = tree
265     def __enter__(self):
266         self.cwd = os.getcwd()
267         os.chdir('%s/src/%s' % (self.tree.target.directory, self.tree.name))
268     def __exit__(self, type, value, traceback):
269         os.chdir(self.cwd)
270
271 #
272 # Version
273 #
274
275 class Version:
276     def __init__(self, s):
277         self.devel = False
278
279         if s.startswith("'"):
280             s = s[1:]
281         if s.endswith("'"):
282             s = s[0:-1]
283
284         if s.endswith('devel'):
285             s = s[0:-5]
286             self.devel = True
287
288         if s.endswith('pre'):
289             s = s[0:-3]
290
291         p = s.split('.')
292         self.major = int(p[0])
293         self.minor = int(p[1])
294         if len(p) == 3:
295             self.micro = int(p[2])
296         else:
297             self.micro = 0
298
299     def bump_minor(self):
300         self.minor += 1
301         self.micro = 0
302
303     def bump_micro(self):
304         self.micro += 1
305
306     def to_devel(self):
307         self.devel = True
308
309     def to_release(self):
310         self.devel = False
311
312     def __str__(self):
313         s = '%d.%d.%d' % (self.major, self.minor, self.micro)
314         if self.devel:
315             s += 'devel'
316
317         return s
318
319 #
320 # Targets
321 #
322
323 class Target(object):
324     """
325     Class representing the target that we are building for.  This is exposed to cscripts,
326     though not all of it is guaranteed 'API'.  cscripts may expect:
327
328     platform: platform string (e.g. 'windows', 'linux', 'osx')
329     parallel: number of parallel jobs to run
330     directory: directory to work in
331     variables: dict of environment variables
332     debug: True to build a debug version, otherwise False
333     set(a, b): set the value of variable 'a' to 'b'
334     unset(a): unset the value of variable 'a'
335     command(c): run the command 'c' in the build environment
336
337     """
338
339     def __init__(self, platform, directory=None):
340         """
341         platform -- platform string (e.g. 'windows', 'linux', 'osx')
342         directory -- directory to work in; if None we will use a temporary directory
343         Temporary directories will be removed after use; specified directories will not.
344         """
345         self.platform = platform
346         self.parallel = int(config.get('parallel'))
347
348         # self.directory is the working directory
349         if directory is None:
350             self.directory = tempfile.mkdtemp('', 'tmp', TEMPORARY_DIRECTORY)
351             self.rmdir = True
352         else:
353             self.directory = directory
354             self.rmdir = False
355
356         # Environment variables that we will use when we call cscripts
357         self.variables = {}
358         self.debug = False
359
360     def package(self, project, checkout, output_dir):
361         tree = globals.trees.get(project, checkout, self)
362         tree.build_dependencies()
363         tree.build()
364         packages = tree.call('package', tree.version)
365         if isinstance(packages, (str, unicode)):
366             copyfile(packages, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, packages))))
367         else:
368             for p in packages:
369                 copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
370
371     def test(self, tree, test):
372         """test is the test case to run, or None"""
373         tree.build_dependencies()
374         tree.build()
375         return tree.call('test', test)
376
377     def set(self, a, b):
378         self.variables[a] = b
379
380     def unset(self, a):
381         del(self.variables[a])
382
383     def get(self, a):
384         return self.variables[a]
385
386     def append_with_space(self, k, v):
387         if (not k in self.variables) or len(self.variables[k]) == 0:
388             self.variables[k] = '"%s"' % v
389         else:
390             e = self.variables[k]
391             if e[0] == '"' and e[-1] == '"':
392                 self.variables[k] = '"%s %s"' % (e[1:-1], v)
393             else:
394                 self.variables[k] = '"%s %s"' % (e, v)
395
396     def variables_string(self, escaped_quotes=False):
397         e = ''
398         for k, v in self.variables.items():
399             if escaped_quotes:
400                 v = v.replace('"', '\\"')
401             e += '%s=%s ' % (k, v)
402         return e
403
404     def cleanup(self):
405         if self.rmdir:
406             rmtree(self.directory)
407
408
409 class WindowsTarget(Target):
410     """
411     This target exposes the following additional API:
412
413     version: Windows version ('xp' or None)
414     bits: bitness of Windows (32 or 64)
415     environment_prefix: path to Windows environment
416     mingw_path: path to mingw binaries
417     """
418     def __init__(self, version, bits, directory=None):
419         super(WindowsTarget, self).__init__('windows', directory)
420         self.version = version
421         self.bits = bits
422
423         self.environment_prefix = '%s/%d' % (config.get('windows_environment_prefix'), self.bits)
424         if not os.path.exists(self.environment_prefix):
425             raise Error('environment prefix %s does not exist' % self.environment_prefix)
426
427         if self.bits == 32:
428             self.mingw_name = 'i686'
429         else:
430             self.mingw_name = 'x86_64'
431
432         self.mingw_path = '%s/%d/bin' % (config.get('mingw_prefix'), self.bits)
433         self.mingw_prefixes = ['/%s/%d' % (config.get('mingw_prefix'), self.bits), '%s/%d/%s-w64-mingw32' % (config.get('mingw_prefix'), bits, self.mingw_name)]
434
435         self.set('PKG_CONFIG_LIBDIR', '%s/lib/pkgconfig' % self.environment_prefix)
436         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
437         self.set('PATH', '%s/bin:%s:%s' % (self.environment_prefix, self.mingw_path, os.environ['PATH']))
438         self.set('CC', '%s-w64-mingw32-gcc' % self.mingw_name)
439         self.set('CXX', '%s-w64-mingw32-g++' % self.mingw_name)
440         self.set('LD', '%s-w64-mingw32-ld' % self.mingw_name)
441         self.set('RANLIB', '%s-w64-mingw32-ranlib' % self.mingw_name)
442         self.set('WINRC', '%s-w64-mingw32-windres' % self.mingw_name)
443         cxx = '-I%s/include -I%s/include' % (self.environment_prefix, self.directory)
444         link = '-L%s/lib -L%s/lib' % (self.environment_prefix, self.directory)
445         for p in self.mingw_prefixes:
446             cxx += ' -I%s/include' % p
447             link += ' -L%s/lib' % p
448         self.set('CXXFLAGS', '"%s"' % cxx)
449         self.set('CPPFLAGS', '')
450         self.set('LINKFLAGS', '"%s"' % link)
451         self.set('LDFLAGS', '"%s"' % link)
452
453         # This is for backwards-compatibility
454         self.windows_prefix = self.environment_prefix
455
456     def command(self, c):
457         log('host -> %s' % c)
458         command('%s %s' % (self.variables_string(), c))
459
460 class LinuxTarget(Target):
461     """
462     Build for Linux in a docker container.
463     This target exposes the following additional API:
464
465     distro: distribution ('debian', 'ubuntu', 'centos' or 'fedora')
466     version: distribution version (e.g. '12.04', '8', '6.5')
467     bits: bitness of the distribution (32 or 64)
468     """
469
470     def __init__(self, distro, version, bits, directory=None):
471         super(LinuxTarget, self).__init__('linux', directory)
472         self.distro = distro
473         self.version = version
474         self.bits = bits
475
476         self.set('CXXFLAGS', '-I%s/include' % self.directory)
477         self.set('CPPFLAGS', '')
478         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
479         self.set('PKG_CONFIG_PATH',
480                  '%s/lib/pkgconfig:%s/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/local/lib/pkgconfig' % (self.directory, self.directory))
481         self.set('PATH', '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin')
482
483 class DockerTarget(LinuxTarget):
484     """Build in a docker container"""
485     def __init__(self, distro, version, bits, directory=None):
486         super(DockerTarget, self).__init__(distro, version, bits, directory)
487
488     def package(self, project, checkout, output_dir):
489         ch = ''
490         if checkout is not None:
491             ch = '-c %s' % checkout
492         target = '%s-%s-%s' % (self.distro, self.version, self.bits)
493         container = command_and_read('%s run -itd %s /bin/bash' % (config.docker(), target)).read().strip()
494         command('%s exec -t %s /bin/bash -c "cdist -p %s -t %s -d %s package -w /cdist"' % (config.docker(), container, project, target, ch))
495         # I can't get wildcards to work with docker cp
496         debs = command_and_read('%s exec -t %s ls -1 /%s' % (config.docker(), container, target)).read().split('\n')
497         for d in debs:
498             d = d.strip()
499             if len(d) > 0:
500                 command('%s cp %s:/%s/%s %s' % (config.docker(), container, target, d, output_dir))
501         command('%s kill %s' % (config.docker(), container))
502
503 class DirectTarget(LinuxTarget):
504     """Build directly in the current environment"""
505     def __init__(self, distro, version, bits, directory=None):
506         super(DirectTarget, self).__init__(distro, version, bits, directory)
507
508     def command(self, c):
509         command('%s %s' % (self.variables_string(), c))
510
511
512 class OSXTarget(Target):
513     def __init__(self, directory=None):
514         super(OSXTarget, self).__init__('osx', directory)
515         self.sdk = config.get('osx_sdk')
516         self.sdk_prefix = config.get('osx_sdk_prefix')
517         self.environment_prefix = config.get('osx_environment_prefix')
518
519     def command(self, c):
520         command('%s %s' % (self.variables_string(False), c))
521
522
523 class OSXSingleTarget(OSXTarget):
524     def __init__(self, bits, directory=None):
525         super(OSXSingleTarget, self).__init__(directory)
526         self.bits = bits
527
528         if bits == 32:
529             arch = 'i386'
530         else:
531             arch = 'x86_64'
532
533         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (self.sdk_prefix, self.sdk, arch)
534         enviro = '%s/%d' % (config.get('osx_environment_prefix'), bits)
535
536         # Environment variables
537         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
538         self.set('CPPFLAGS', '')
539         self.set('CXXFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
540         self.set('LDFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
541         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
542         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, enviro))
543         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % enviro)
544         self.set('MACOSX_DEPLOYMENT_TARGET', config.get('osx_sdk'))
545
546     def package(self, project, checkout, output_dir):
547         raise Error('cannot package non-universal OS X versions')
548
549
550 class OSXUniversalTarget(OSXTarget):
551     def __init__(self, directory=None):
552         super(OSXUniversalTarget, self).__init__(directory)
553
554     def package(self, project, checkout, output_dir):
555
556         for b in [32, 64]:
557             target = OSXSingleTarget(b, os.path.join(self.directory, '%d' % b))
558             tree = globals.trees.get(project, checkout, target)
559             tree.build_dependencies()
560             tree.build()
561
562         tree = globals.trees.get(project, checkout, self)
563         with TreeDirectory(tree):
564             for p in tree.call('package', tree.version):
565                 copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
566
567 class SourceTarget(Target):
568     """Build a source .tar.bz2"""
569     def __init__(self):
570         super(SourceTarget, self).__init__('source')
571
572     def command(self, c):
573         log('host -> %s' % c)
574         command('%s %s' % (self.variables_string(), c))
575
576     def cleanup(self):
577         rmtree(self.directory)
578
579     def package(self, project, checkout, output_dir):
580         tree = globals.trees.get(project, checkout, self)
581         with TreeDirectory(tree):
582             name = read_wscript_variable(os.getcwd(), 'APPNAME')
583             command('./waf dist')
584             p = os.path.abspath('%s-%s.tar.bz2' % (name, tree.version))
585             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
586
587 # @param s Target string:
588 #       windows-{32,64}
589 #    or ubuntu-version-{32,64}
590 #    or debian-version-{32,64}
591 #    or centos-version-{32,64}
592 #    or fedora-version-{32,64}
593 #    or osx-{32,64}
594 #    or source
595 # @param debug True to build with debugging symbols (where possible)
596 def target_factory(s, direct, debug, work):
597     target = None
598     if s.startswith('windows-'):
599         x = s.split('-')
600         if len(x) == 2:
601             target = WindowsTarget(None, int(x[1]), work)
602         elif len(x) == 3:
603             target = WindowsTarget(x[1], int(x[2]), work)
604         else:
605             raise Error("Bad Windows target name `%s'")
606     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-') or s.startswith('fedora'):
607         p = s.split('-')
608         if len(p) != 3:
609             raise Error("Bad Linux target name `%s'; must be something like ubuntu-12.04-32 (i.e. distro-version-bits)" % s)
610         if direct:
611             target = DirectTarget(p[0], p[1], int(p[2]), work)
612         else:
613             target = DockerTarget(p[0], p[1], int(p[2]), work)
614     elif s.startswith('arch-'):
615         p = s.split('-')
616         if len(p) != 2:
617             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
618         target = DockerTarget(p[0], None, p[1], work)
619     elif s == 'raspbian':
620         target = DockerTarget(s, None, None, work)
621     elif s.startswith('osx-'):
622         target = OSXSingleTarget(int(s.split('-')[1]), work)
623     elif s == 'osx':
624         if globals.command == 'build':
625             target = OSXSingleTarget(64, work)
626         else:
627             target = OSXUniversalTarget(work)
628     elif s == 'source':
629         target = SourceTarget()
630     elif s == 'docker':
631         target = DockerTarget()
632
633     if target is None:
634         raise Error("Bad target `%s'" % s)
635
636     target.debug = debug
637     return target
638
639
640 #
641 # Tree
642 #
643
644 class Tree(object):
645     """Description of a tree, which is a checkout of a project,
646        possibly built.  This class is never exposed to cscripts.
647        Attributes:
648            name -- name of git repository (without the .git)
649            specifier -- git tag or revision to use
650            target --- target object that we are using
651            version --- version from the wscript (if one is present)
652            git_commit -- git revision that is actually being used
653            built --- true if the tree has been built yet in this run
654     """
655
656     def __init__(self, name, specifier, target):
657         self.name = name
658         self.specifier = specifier
659         self.target = target
660         self.version = None
661         self.git_commit = None
662         self.built = False
663
664         cwd = os.getcwd()
665
666         flags = ''
667         redirect = ''
668         if globals.quiet:
669             flags = '-q'
670             redirect = '>/dev/null'
671         command('git clone %s %s/%s.git %s/src/%s' % (flags, config.get('git_prefix'), self.name, target.directory, self.name))
672         os.chdir('%s/src/%s' % (target.directory, self.name))
673
674         spec = self.specifier
675         if spec is None:
676             spec = 'master'
677
678         command('git checkout %s %s %s' % (flags, spec, redirect))
679         self.git_commit = command_and_read('git rev-parse --short=7 HEAD').readline().strip()
680         command('git submodule init --quiet')
681         command('git submodule update --quiet')
682
683         proj = '%s/src/%s' % (target.directory, self.name)
684
685         self.cscript = {}
686         exec(open('%s/cscript' % proj).read(), self.cscript)
687
688         if os.path.exists('%s/wscript' % proj):
689             v = read_wscript_variable(proj, "VERSION");
690             if v is not None:
691                 self.version = Version(v)
692
693         os.chdir(cwd)
694
695     def call(self, function, *args):
696         with TreeDirectory(self):
697             return self.cscript[function](self.target, *args)
698
699     def build_dependencies(self, options=None):
700
701         if not 'dependencies' in self.cscript:
702             return
703
704         if len(inspect.getargspec(self.cscript['dependencies']).args) == 2:
705             deps = self.call('dependencies', options)
706         else:
707             log("Deprecated cscipt dependencies() method with no parameter")
708             deps = self.call('dependencies')
709
710         for d in deps:
711             dep = globals.trees.get(d[0], d[1], self.target)
712
713             options = dict()
714             # Make the options to pass in from the option_defaults of the thing
715             # we are building and any options specified by the parent.
716             if 'option_defaults' in dep.cscript:
717                 for k, v in dep.cscript['option_defaults']().items():
718                     options[k] = v
719
720             if len(d) > 2:
721                 for k, v in d[2].items():
722                     options[k] = v
723
724             msg = 'Building dependency %s %s of %s' % (d[0], d[1], self.name)
725             if len(options) > 0:
726                 msg += ' with options %s' % options
727             log(msg)
728
729             dep.build_dependencies(options)
730             dep.build(options)
731
732     def build(self, options=None):
733         if self.built:
734             return
735
736         variables = copy.copy(self.target.variables)
737
738         if not globals.dry_run:
739             if len(inspect.getargspec(self.cscript['build']).args) == 2:
740                 self.call('build', options)
741             else:
742                 self.call('build')
743
744         self.target.variables = variables
745         self.built = True
746
747 #
748 # Command-line parser
749 #
750
751 def main():
752
753     commands = {
754         "build": "build project",
755         "package": "package and build project",
756         "release": "release a project using its next version number (changing wscript and tagging)",
757         "pot": "build the project's .pot files",
758         "changelog": "generate a simple HTML changelog",
759         "manual": "build the project's manual",
760         "doxygen": "build the project's Doxygen documentation",
761         "latest": "print out the latest version",
762         "test": "run the project's unit tests",
763         "shell": "build the project then start a shell",
764         "checkout": "check out the project",
765         "revision": "print the head git revision number"
766     }
767
768     one_of = "Command is one of:\n"
769     summary = ""
770     for k, v in commands.items():
771         one_of += "\t%s\t%s\n" % (k, v)
772         summary += k + " "
773
774     parser = argparse.ArgumentParser()
775     parser.add_argument('command', help=summary)
776     parser.add_argument('-p', '--project', help='project name')
777     parser.add_argument('--minor', help='minor version number bump', action='store_true')
778     parser.add_argument('--micro', help='micro version number bump', action='store_true')
779     parser.add_argument('--major', help='major version to return with latest', type=int)
780     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
781     parser.add_argument('-o', '--output', help='output directory', default='.')
782     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
783     parser.add_argument('-t', '--target', help='target')
784     parser.add_argument('-d', '--direct', help='build in the current environment', action='store_true')
785     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
786     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
787     parser.add_argument('-w', '--work', help='override default work directory')
788     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
789     parser.add_argument('--test', help='name of test to run (with `test''), defaults to all')
790     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
791     args = parser.parse_args()
792
793     # Override configured stuff
794     if args.git_prefix is not None:
795         config.set('git_prefix', args.git_prefix)
796
797     if args.output.find(':') == -1:
798         # This isn't of the form host:path so make it absolute
799         args.output = os.path.abspath(args.output) + '/'
800     else:
801         if args.output[-1] != ':' and args.output[-1] != '/':
802             args.output += '/'
803
804     # Now, args.output is 'host:', 'host:path/' or 'path/'
805
806     if args.work is not None:
807         args.work = os.path.abspath(args.work)
808
809     if args.project is None and args.command != 'shell':
810         raise Error('you must specify -p or --project')
811
812     globals.quiet = args.quiet
813     globals.command = args.command
814     globals.dry_run = args.dry_run
815
816     if not globals.command in commands:
817         e = 'command must be one of:\n' + one_of
818         raise Error('command must be one of:\n%s' % one_of)
819
820     if globals.command == 'build':
821         if args.target is None:
822             raise Error('you must specify -t or --target')
823
824         target = target_factory(args.target, args.direct, args.debug, args.work)
825         tree = globals.trees.get(args.project, args.checkout, target)
826         tree.build_dependencies()
827         tree.build()
828         if not args.keep:
829             target.cleanup()
830
831     elif globals.command == 'package':
832         if args.target is None:
833             raise Error('you must specify -t or --target')
834
835         target = target_factory(args.target, args.direct, args.debug, args.work)
836
837         if target.platform == 'linux':
838             output_dir = os.path.join(args.output, '%s-%s-%d' % (target.distro, target.version, target.bits))
839         else:
840             output_dir = args.output
841
842         makedirs(output_dir)
843         target.package(args.project, args.checkout, output_dir)
844
845         if not args.keep:
846             target.cleanup()
847
848     elif globals.command == 'release':
849         if args.minor is False and args.micro is False:
850             raise Error('you must specify --minor or --micro')
851
852         target = SourceTarget()
853         tree = globals.trees.get(args.project, args.checkout, target)
854
855         version = tree.version
856         version.to_release()
857         if args.minor:
858             version.bump_minor()
859         else:
860             version.bump_micro()
861
862         set_version_in_wscript(version)
863         append_version_to_changelog(version)
864         append_version_to_debian_changelog(version)
865
866         command('git commit -a -m "Bump version"')
867         command('git tag -m "v%s" v%s' % (version, version))
868
869         version.to_devel()
870         set_version_in_wscript(version)
871         command('git commit -a -m "Bump version"')
872         command('git push')
873         command('git push --tags')
874
875         target.cleanup()
876
877     elif globals.command == 'pot':
878         target = SourceTarget()
879         tree = globals.trees.get(args.project, args.checkout, target)
880
881         pots = tree.call('make_pot')
882         for p in pots:
883             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
884
885         target.cleanup()
886
887     elif globals.command == 'changelog':
888         target = SourceTarget()
889         tree = globals.trees.get(args.project, args.checkout, target)
890
891         with TreeDirectory(tree):
892             text = open('ChangeLog', 'r')
893
894         html = tempfile.NamedTemporaryFile()
895         versions = 8
896
897         last = None
898         changes = []
899
900         while True:
901             l = text.readline()
902             if l == '':
903                 break
904
905             if len(l) > 0 and l[0] == "\t":
906                 s = l.split()
907                 if len(s) == 4 and s[1] == "Version" and s[3] == "released.":
908                     v = Version(s[2])
909                     if v.micro == 0:
910                         if last is not None and len(changes) > 0:
911                             print("<h2>Changes between version %s and %s</h2>" % (s[2], last), file=html)
912                             print("<ul>", file=html)
913                             for c in changes:
914                                 print("<li>%s" % c, file=html)
915                             print("</ul>", file=html)
916                         last = s[2]
917                         changes = []
918                         versions -= 1
919                         if versions < 0:
920                             break
921                 else:
922                     c = l.strip()
923                     if len(c) > 0:
924                         if c[0] == '*':
925                             changes.append(c[2:])
926                         else:
927                             changes[-1] += " " + c
928
929         copyfile(html.file, '%schangelog.html' % args.output)
930         html.close()
931         target.cleanup()
932
933     elif globals.command == 'manual':
934         target = SourceTarget()
935         tree = globals.trees.get(args.project, args.checkout, target)
936
937         outs = tree.call('make_manual')
938         for o in outs:
939             if os.path.isfile(o):
940                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
941             else:
942                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
943
944         target.cleanup()
945
946     elif globals.command == 'doxygen':
947         target = SourceTarget()
948         tree = globals.trees.get(args.project, args.checkout, target)
949
950         dirs = tree.call('make_doxygen')
951         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
952             dirs = [dirs]
953
954         for d in dirs:
955             copytree(d, args.output)
956
957         target.cleanup()
958
959     elif globals.command == 'latest':
960         target = SourceTarget()
961         tree = globals.trees.get(args.project, args.checkout, target)
962
963         with TreeDirectory(tree):
964             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
965             latest = None
966             while latest is None:
967                 t = f.readline()
968                 m = re.compile(".*\((.*)\).*").match(t)
969                 if m:
970                     tags = m.group(1).split(', ')
971                     for t in tags:
972                         s = t.split()
973                         if len(s) > 1:
974                             t = s[1]
975                         if len(t) > 0 and t[0] == 'v':
976                             v = Version(t[1:])
977                             if args.major is None or v.major == args.major:
978                                 latest = v
979
980         print(latest)
981         target.cleanup()
982
983     elif globals.command == 'test':
984         if args.target is None:
985             raise Error('you must specify -t or --target')
986
987         target = None
988         try:
989             target = target_factory(args.target, args.direct, args.debug, args.work)
990             tree = globals.trees.get(args.project, args.checkout, target)
991             with TreeDirectory(tree):
992                 target.test(tree, args.test)
993         except Error as e:
994             if target is not None:
995                 target.cleanup()
996             raise
997
998         if target is not None:
999             target.cleanup()
1000
1001     elif globals.command == 'shell':
1002         if args.target is None:
1003             raise Error('you must specify -t or --target')
1004
1005         target = target_factory(args.target, args.direct, args.debug, args.work)
1006         target.command('bash')
1007
1008     elif globals.command == 'revision':
1009
1010         target = SourceTarget()
1011         tree = globals.trees.get(args.project, args.checkout, target)
1012         with TreeDirectory(tree):
1013             print(command_and_read('git rev-parse HEAD').readline().strip()[:7])
1014         target.cleanup()
1015
1016     elif globals.command == 'checkout':
1017
1018         if args.output is None:
1019             raise Error('you must specify -o or --output')
1020
1021         target = SourceTarget()
1022         tree = globals.trees.get(args.project, args.checkout, target)
1023         with TreeDirectory(tree):
1024             shutil.copytree('.', args.output)
1025         target.cleanup()
1026
1027     else:
1028         raise Error('invalid command %s' % globals.command)
1029
1030 try:
1031     main()
1032 except Error as e:
1033     print('cdist: %s' % str(e), file=sys.stderr)
1034     sys.exit(1)