Always assume home/tmp are mounted in chroots; remove the different directory stuff.
[cdist.git] / cdist
1 #!/usr/bin/python
2
3 #    Copyright (C) 2012-2014 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 import os
20 import sys
21 import shutil
22 import glob
23 import tempfile
24 import argparse
25 import datetime
26 import subprocess
27 import re
28 import copy
29 import inspect
30
31 TEMPORARY_DIRECTORY = '/tmp'
32
33 class Error(Exception):
34     def __init__(self, value):
35         self.value = value
36     def __str__(self):
37         return '\x1b[31m%s\x1b[0m' % repr(self.value)
38     def __repr__(self):
39         return str(self)
40
41 #
42 # Configuration
43 #
44
45 class Option(object):
46     def __init__(self, key):
47         self.key = key
48         self.value = None
49
50     def offer(self, key, value):
51         if key == self.key:
52             self.value = value
53
54 class BoolOption(object):
55     def __init__(self, key):
56         self.key = key
57         self.value = False
58
59     def offer(self, key, value):
60         if key == self.key:
61             self.value = (value == 'yes' or value == '1' or value == 'true')
62
63 class Config:
64     def __init__(self):
65         self.options = [ Option('linux_chroot_prefix'),
66                          Option('windows_environment_prefix'),
67                          Option('mingw_prefix'),
68                          Option('git_prefix'),
69                          Option('osx_build_host'),
70                          Option('osx_environment_prefix'),
71                          Option('osx_sdk_prefix'),
72                          Option('osx_sdk') ]
73
74         try:
75             f = open('%s/.config/cdist' % os.path.expanduser('~'), 'r')
76             while 1:
77                 l = f.readline()
78                 if l == '':
79                     break
80
81                 if len(l) > 0 and l[0] == '#':
82                     continue
83
84                 s = l.strip().split()
85                 if len(s) == 2:
86                     for k in self.options:
87                         k.offer(s[0], s[1])
88         except:
89             raise
90
91     def get(self, k):
92         for o in self.options:
93             if o.key == k:
94                 return o.value
95
96         raise Error('Required setting %s not found' % k)
97
98 config = Config()
99
100 #
101 # Utility bits
102
103
104 def log(m):
105     if not args.quiet:
106         print '\x1b[33m* %s\x1b[0m' % m
107
108 def copytree(a, b):
109     log('copy %s -> %s' % (a, b))
110     shutil.copytree(a, b)
111
112 def copyfile(a, b):
113     log('copy %s -> %s' % (a, b))
114     shutil.copyfile(a, b)
115
116 def rmdir(a):
117     log('remove %s' % a)
118     os.rmdir(a)
119
120 def rmtree(a):
121     log('remove %s' % a)
122     shutil.rmtree(a, ignore_errors=True)
123
124 def command(c, can_fail=False):
125     log(c)
126     r = os.system(c)
127     if (r >> 8) and not can_fail:
128         raise Error('command %s failed' % c)
129
130 def command_and_read(c):
131     log(c)
132     p = subprocess.Popen(c.split(), stdout=subprocess.PIPE)
133     f = os.fdopen(os.dup(p.stdout.fileno()))
134     return f
135
136 def read_wscript_variable(directory, variable):
137     f = open('%s/wscript' % directory, 'r')
138     while 1:
139         l = f.readline()
140         if l == '':
141             break
142         
143         s = l.split()
144         if len(s) == 3 and s[0] == variable:
145             f.close()
146             return s[2][1:-1]
147
148     f.close()
149     return None
150
151 #
152 # Version
153 #
154
155 class Version:
156     def __init__(self, s):
157         self.devel = False
158
159         if s.startswith("'"):
160             s = s[1:]
161         if s.endswith("'"):
162             s = s[0:-1]
163         
164         if s.endswith('devel'):
165             s = s[0:-5]
166             self.devel = True
167
168         if s.endswith('pre'):
169             s = s[0:-3]
170
171         p = s.split('.')
172         self.major = int(p[0])
173         self.minor = int(p[1])
174         if len(p) == 3:
175             self.micro = int(p[2])
176         else:
177             self.micro = 0
178
179     def bump_minor(self):
180         self.minor += 1
181         self.micro = 0
182
183     def bump_micro(self):
184         self.micro += 1
185
186     def to_devel(self):
187         self.devel = True
188
189     def to_release(self):
190         self.devel = False
191
192     def __str__(self):
193         s = '%d.%d.%d' % (self.major, self.minor, self.micro)
194         if self.devel:
195             s += 'devel'
196
197         return s
198
199 #
200 # Targets
201 #
202
203 class Target(object):
204     # @param directory directory to work in; if None we will use a temporary directory
205     # Temporary directories will be removed after use; specified directories will not
206     def __init__(self, platform, parallel, directory=None):
207         self.platform = platform
208         self.parallel = parallel
209
210         if directory is None:
211             self.directory = tempfile.mkdtemp('', 'tmp', TEMPORARY_DIRECTORY)
212             self.rmdir = True
213         else:
214             self.directory = directory
215             self.rmdir = False
216
217         print 'Working in %s' % self.directory
218
219         # Environment variables that we will use when we call cscripts
220         self.variables = {}
221         self.debug = False
222
223     def build_dependencies(self, project):
224         cwd = os.getcwd()
225         if 'dependencies' in project.cscript:
226             for d in project.cscript['dependencies'](self):
227                 log('Building dependency %s %s of %s' % (d[0], d[1], project.name))
228                 dep = Project(d[0], '.', d[1])
229                 dep.checkout(self)
230                 self.build_dependencies(dep)
231
232                 # Make the options to pass in from the option_defaults of the thing
233                 # we are building and any options specified by the parent.
234                 options = {}
235                 if 'option_defaults' in dep.cscript:
236                     options = dep.cscript['option_defaults']()
237                     if len(d) > 2:
238                         for k, v in d[2].iteritems():
239                             options[k] = v
240
241                 self.build(dep, options)
242
243         os.chdir(cwd)
244
245     def build(self, project, options=None):
246         variables = copy.copy(self.variables)
247         if len(inspect.getargspec(project.cscript['build']).args) == 2:
248             project.cscript['build'](self, options)
249         else:
250             project.cscript['build'](self)
251         self.variables = variables
252
253     def package(self, project):
254         project.checkout(self)
255         self.build_dependencies(project)
256         self.build(project)
257         return project.cscript['package'](self, project.version)
258
259     def test(self, project):
260         project.checkout(self)
261         self.build_dependencies(project)
262         self.build(project)
263         project.cscript['test'](self)
264
265     def set(self, a, b):
266         self.variables[a] = b
267
268     def unset(self, a):
269         del(self.variables[a])
270
271     def get(self, a):
272         return self.variables[a]
273
274     def append_with_space(self, k, v):
275         if not k in self.variables:
276             self.variables[k] = v
277         else:
278             self.variables[k] = '%s %s' % (self.variables[k], v)
279
280     def variables_string(self, escaped_quotes=False):
281         e = ''
282         for k, v in self.variables.iteritems():
283             if escaped_quotes:
284                 v = v.replace('"', '\\"')
285             e += '%s=%s ' % (k, v)
286         return e
287
288     def cleanup(self):
289         if self.rmdir:
290             rmtree(self.directory)
291
292
293 # Windows
294 #
295
296 class WindowsTarget(Target):
297     def __init__(self, bits, directory=None):
298         super(WindowsTarget, self).__init__('windows', 2, directory)
299         self.bits = bits
300
301         self.windows_prefix = '%s/%d' % (config.get('windows_environment_prefix'), self.bits)
302         if not os.path.exists(self.windows_prefix):
303             raise Error('windows prefix %s does not exist' % self.windows_prefix)
304             
305         if self.bits == 32:
306             self.mingw_name = 'i686'
307         else:
308             self.mingw_name = 'x86_64'
309
310         mingw_path = '%s/%d/bin' % (config.get('mingw_prefix'), self.bits)
311         self.mingw_prefixes = ['/%s/%d' % (config.get('mingw_prefix'), self.bits), '%s/%d/%s-w64-mingw32' % (config.get('mingw_prefix'), bits, self.mingw_name)]
312
313         self.set('PKG_CONFIG_LIBDIR', '%s/lib/pkgconfig' % self.windows_prefix)
314         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
315         self.set('PATH', '%s/bin:%s:%s' % (self.windows_prefix, mingw_path, os.environ['PATH']))
316         self.set('CC', '%s-w64-mingw32-gcc' % self.mingw_name)
317         self.set('CXX', '%s-w64-mingw32-g++' % self.mingw_name)
318         self.set('LD', '%s-w64-mingw32-ld' % self.mingw_name)
319         self.set('RANLIB', '%s-w64-mingw32-ranlib' % self.mingw_name)
320         self.set('WINRC', '%s-w64-mingw32-windres' % self.mingw_name)
321         cxx = '-I%s/include -I%s/include' % (self.windows_prefix, self.directory)
322         link = '-L%s/lib -L%s/lib' % (self.windows_prefix, self.directory)
323         for p in self.mingw_prefixes:
324             cxx += ' -I%s/include' % p
325             link += ' -L%s/lib' % p
326         self.set('CXXFLAGS', '"%s"' % cxx)
327         self.set('LINKFLAGS', '"%s"' % link)
328
329     def command(self, c):
330         log('host -> %s' % c)
331         command('%s %s' % (self.variables_string(), c))
332
333 #
334 # Linux
335 #
336
337 class LinuxTarget(Target):
338     def __init__(self, distro, version, bits, directory=None):
339         super(LinuxTarget, self).__init__('linux', 2, directory)
340         self.distro = distro
341         self.version = version
342         self.bits = bits
343         # e.g. ubuntu-14.04-64
344         self.chroot = '%s-%s-%d' % (self.distro, self.version, self.bits)
345         # e.g. /home/carl/Environments/ubuntu-14.04-64
346         self.chroot_prefix = '%s/%s' % (config.get('linux_chroot_prefix'), self.chroot)
347
348         self.set('CXXFLAGS', '-I%s/include' % self.directory)
349         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
350         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:/usr/local/lib/pkgconfig' % self.directory)
351         self.set('PATH', '%s:/usr/local/bin' % (os.environ['PATH']))
352
353     def command(self, c):
354         command('%s schroot -c %s -p -- %s' % (self.variables_string(), self.chroot, c))
355
356 #
357 # OS X
358 #
359
360 class OSXTarget(Target):
361     def __init__(self, directory=None):
362         super(OSXTarget, self).__init__('osx', 4, directory)
363
364     def command(self, c):
365         command('%s %s' % (self.variables_string(False), c))
366
367
368 class OSXSingleTarget(OSXTarget):
369     def __init__(self, bits, directory=None):
370         super(OSXSingleTarget, self).__init__(directory)
371         self.bits = bits
372
373         if bits == 32:
374             arch = 'i386'
375         else:
376             arch = 'x86_64'
377
378         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (config.get('osx_sdk_prefix'), config.get('osx_sdk'), arch)
379         enviro = '%s/%d' % (config.get('osx_environment_prefix'), bits)
380
381         # Environment variables
382         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
383         self.set('CXXFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
384         self.set('LDFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
385         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
386         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, enviro))
387         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % enviro)
388         self.set('MACOSX_DEPLOYMENT_TARGET', config.get('osx_sdk'))
389
390     def package(self, project):
391         raise Error('cannot package non-universal OS X versions')
392
393
394 class OSXUniversalTarget(OSXTarget):
395     def __init__(self, directory=None):
396         super(OSXUniversalTarget, self).__init__(directory)
397         self.parts = []
398         self.parts.append(OSXSingleTarget(32, directory))
399         self.parts.append(OSXSingleTarget(64, directory))
400
401     def package(self, project):
402         for p in self.parts:
403             project.checkout(p)
404             p.build_dependencies(project)
405             p.build(project)
406
407         return project.cscript['package'](self, project.version)
408     
409
410 #
411 # Source
412 #
413
414 class SourceTarget(Target):
415     def __init__(self):
416         super(SourceTarget, self).__init__('source', 2)
417         self.directory = tempfile.mkdtemp()
418
419     def command(self, c):
420         log('host -> %s' % c)
421         command('%s %s' % (self.variables_string(), c))
422
423     def cleanup(self):
424         rmtree(self.directory)
425
426     def package(self, project):
427         project.checkout(self)
428         name = read_wscript_variable(os.getcwd(), 'APPNAME')
429         command('./waf dist')
430         return os.path.abspath('%s-%s.tar.bz2' % (name, project.version))
431
432
433 # @param s Target string:
434 #       windows-{32,64}
435 #    or ubuntu-version-{32,64}
436 #    or debian-version-{32,64}
437 #    or centos-version-{32,64}
438 #    or osx-{32,64}
439 #    or source      
440 # @param debug True to build with debugging symbols (where possible)
441 def target_factory(s, debug, work):
442     target = None
443     if s.startswith('windows-'):
444         target = WindowsTarget(int(s.split('-')[1]), work)
445     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-'):
446         p = s.split('-')
447         if len(p) != 3:
448             print >>sys.stderr,"Bad Linux target name `%s'; must be something like ubuntu-12.04-32 (i.e. distro-version-bits)" % s
449             sys.exit(1)
450         target = LinuxTarget(p[0], p[1], int(p[2]), work)
451     elif s.startswith('osx-'):
452         target = OSXSingleTarget(int(s.split('-')[1]), work)
453     elif s == 'osx':
454         if args.command == 'build':
455             target = OSXSingleTarget(64, work)
456         else:
457             target = OSXUniversalTarget(work)
458     elif s == 'source':
459         target = SourceTarget()
460
461     if target is not None:
462         target.debug = debug
463
464     return target
465
466
467 #
468 # Project
469 #
470  
471 class Project(object):
472     def __init__(self, name, directory, specifier=None):
473         self.name = name
474         self.directory = directory
475         self.version = None
476         self.specifier = specifier
477         self.git_commit = None
478         if self.specifier is None:
479             self.specifier = 'master'
480
481     def checkout(self, target):
482         flags = ''
483         redirect = ''
484         if args.quiet:
485             flags = '-q'
486             redirect = '>/dev/null'
487         command('git clone %s %s/%s.git %s/src/%s' % (flags, config.get('git_prefix'), self.name, target.directory, self.name))
488         os.chdir('%s/src/%s' % (target.directory, self.name))
489         command('git checkout %s %s %s' % (flags, self.specifier, redirect))
490         self.git_commit = command_and_read('git rev-parse --short=7 HEAD').readline().strip()
491         command('git submodule init --quiet')
492         command('git submodule update --quiet')
493         os.chdir(self.directory)
494
495         proj = '%s/src/%s/%s' % (target.directory, self.name, self.directory)
496
497         self.read_cscript('%s/cscript' % proj)
498         
499         if os.path.exists('%s/wscript' % proj):
500             v = read_wscript_variable(proj, "VERSION");
501             if v is not None:
502                 self.version = Version(v)
503
504     def read_cscript(self, s):
505         self.cscript = {}
506         execfile(s, self.cscript)
507
508 def set_version_in_wscript(version):
509     f = open('wscript', 'rw')
510     o = open('wscript.tmp', 'w')
511     while 1:
512         l = f.readline()
513         if l == '':
514             break
515
516         s = l.split()
517         if len(s) == 3 and s[0] == "VERSION":
518             print "Writing %s" % version
519             print >>o,"VERSION = '%s'" % version
520         else:
521             print >>o,l,
522     f.close()
523     o.close()
524
525     os.rename('wscript.tmp', 'wscript')
526
527 def append_version_to_changelog(version):
528     try:
529         f = open('ChangeLog', 'r')
530     except:
531         log('Could not open ChangeLog')
532         return
533
534     c = f.read()
535     f.close()
536
537     f = open('ChangeLog', 'w')
538     now = datetime.datetime.now()
539     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))
540     f.write(c)
541
542 def append_version_to_debian_changelog(version):
543     if not os.path.exists('debian'):
544         log('Could not find debian directory')
545         return
546
547     command('dch -b -v %s-1 "New upstream release."' % version)
548
549 def devel_to_git(project, filename):
550     if project.git_commit is not None:
551         filename = filename.replace('devel', '-%s' % project.git_commit)
552     return filename
553
554 #
555 # Command-line parser
556 #
557
558 parser = argparse.ArgumentParser()
559 parser.add_argument('command')
560 parser.add_argument('-p', '--project', help='project name')
561 parser.add_argument('-d', '--directory', help='directory within project repo', default='.')
562 parser.add_argument('--minor', help='minor version number bump', action='store_true')
563 parser.add_argument('--micro', help='micro version number bump', action='store_true')
564 parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
565 parser.add_argument('-o', '--output', help='output directory', default='.')
566 parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
567 parser.add_argument('-t', '--target', help='target')
568 parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
569 parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
570 parser.add_argument('-w', '--work', help='override default work directory')
571 args = parser.parse_args()
572
573 args.output = os.path.abspath(args.output)
574 if args.work is not None:
575     args.work = os.path.abspath(args.work)
576
577 if args.project is None and args.command != 'shell':
578     raise Error('you must specify -p or --project')
579
580 project = Project(args.project, args.directory, args.checkout)
581
582 if args.command == 'build':
583     if args.target is None:
584         raise Error('you must specify -t or --target')
585
586     target = target_factory(args.target, args.debug, args.work)
587     project.checkout(target)
588     target.build_dependencies(project)
589     target.build(project)
590     if not args.keep:
591         target.cleanup()
592
593 elif args.command == 'package':
594     if args.target is None:
595         raise Error('you must specify -t or --target')
596         
597     target = target_factory(args.target, args.debug, args.work)
598
599     packages = target.package(project)
600     if hasattr(packages, 'strip') or (not hasattr(packages, '__getitem__') and not hasattr(packages, '__iter__')):
601         packages = [packages]
602
603     if target.platform == 'linux':
604         out = '%s/%s-%s-%d' % (args.output, target.distro, target.version, target.bits)
605         try:
606             os.makedirs(out)
607         except:
608             pass
609         for p in packages:
610             copyfile(p, '%s/%s' % (out, os.path.basename(devel_to_git(project, p))))
611     else:
612         for p in packages:
613             copyfile(p, '%s/%s' % (args.output, os.path.basename(devel_to_git(project, p))))
614
615     if not args.keep:
616         target.cleanup()
617
618 elif args.command == 'release':
619     if args.minor is False and args.micro is False:
620         raise Error('you must specify --minor or --micro')
621
622     target = SourceTarget()
623     project.checkout(target)
624
625     version = project.version
626     version.to_release()
627     if args.minor:
628         version.bump_minor()
629     else:
630         version.bump_micro()
631
632     set_version_in_wscript(version)
633     append_version_to_changelog(version)
634     append_version_to_debian_changelog(version)
635
636     command('git commit -a -m "Bump version"')
637     command('git tag -m "v%s" v%s' % (version, version))
638
639     version.to_devel()
640     set_version_in_wscript(version)
641     command('git commit -a -m "Bump version"')
642     command('git push')
643     command('git push --tags')
644
645     target.cleanup()
646
647 elif args.command == 'pot':
648     target = SourceTarget()
649     project.checkout(target)
650
651     pots = project.cscript['make_pot'](target)
652     for p in pots:
653         copyfile(p, '%s/%s' % (args.output, os.path.basename(p)))
654
655     target.cleanup()
656
657 elif args.command == 'changelog':
658     target = SourceTarget()
659     project.checkout(target)
660
661     text = open('ChangeLog', 'r')
662     html = open('%s/changelog.html' % args.output, 'w')
663     versions = 8
664     
665     last = None
666     changes = []
667     
668     while 1:
669         l = text.readline()
670         if l == '':
671             break
672     
673         if len(l) > 0 and l[0] == "\t":
674             s = l.split()
675             if len(s) == 4 and s[1] == "Version" and s[3] == "released.":
676                 v = Version(s[2])
677                 if v.micro == 0:
678                     if last is not None and len(changes) > 0:
679                         print >>html,"<h2>Changes between version %s and %s</h2>" % (s[2], last)
680                         print >>html,"<ul>"
681                         for c in changes:
682                             print >>html,"<li>%s" % c
683                         print >>html,"</ul>"
684                     last = s[2]
685                     changes = []
686                     versions -= 1
687                     if versions < 0:
688                         break
689             else:
690                 c = l.strip()
691                 if len(c) > 0:
692                     if c[0] == '*':
693                         changes.append(c[2:])
694                     else:
695                         changes[-1] += " " + c
696
697     target.cleanup()
698
699 elif args.command == 'manual':
700     target = SourceTarget()
701     project.checkout(target)
702
703     outs = project.cscript['make_manual'](target)
704     for o in outs:
705         if os.path.isfile(o):
706             copyfile(o, '%s/%s' % (args.output, os.path.basename(o)))
707         else:
708             copytree(o, '%s/%s' % (args.output, os.path.basename(o)))
709
710     target.cleanup()
711
712 elif args.command == 'doxygen':
713     target = SourceTarget()
714     project.checkout(target)
715
716     dirs = project.cscript['make_doxygen'](target)
717     if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
718         dirs = [dirs]
719
720     for d in dirs:
721         copytree(d, '%s/%s' % (args.output, 'doc'))
722
723     target.cleanup()
724
725 elif args.command == 'latest':
726     target = SourceTarget()
727     project.checkout(target)
728
729     f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
730     t = f.readline()
731     m = re.compile(".*\((.*)\).*").match(t)
732     latest = None
733     if m:
734         tags = m.group(1).split(', ')
735         for t in tags:
736             s = t.split()
737             if len(s) > 1:
738                 t = s[1]
739             if len(t) > 0 and t[0] == 'v':
740                 latest = t[1:]
741
742     print latest
743     target.cleanup()
744
745 elif args.command == 'test':
746     if args.target is None:
747         raise Error('you must specify -t or --target')
748
749     target = None
750     try:
751         target = target_factory(args.target, args.debug, args.work)
752         target.test(project)
753     except Error as e:
754         if target is not None:
755             target.cleanup()
756         raise
757         
758     if target is not None:
759         target.cleanup()
760
761 elif args.command == 'shell':
762     if args.target is None:
763         raise Error('you must specify -t or --target')
764
765     target = target_factory(args.target, args.debug, args.work)
766     target.command('bash')
767
768 else:
769     raise Error('invalid command %s' % args.command)