]> git.siccegge.de Git - forks/vmdebootstrap.git/blob - vmdebootstrap
handle missing customise scripts cleanly with fallback to upstream examples.
[forks/vmdebootstrap.git] / vmdebootstrap
1 #!/usr/bin/python
2 # Copyright 2011-2013 Lars Wirzenius
3 # Copyright 2012 Codethink Limited
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 3 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, see <http://www.gnu.org/licenses/>.
17
18 import cliapp
19 import crypt
20 import logging
21 import os
22 import re
23 import time
24 import shutil
25 import subprocess
26 import tempfile
27 import time
28
29
30 __version__ = '0.3'
31
32
33 class VmDebootstrap(cliapp.Application):
34
35 def add_settings(self):
36 default_arch = self.runcmd(
37 ["dpkg", "--print-architecture"],
38 ignore_fail=False).strip()
39
40 self.settings.boolean(['verbose'], 'report what is going on')
41 self.settings.string(['image'], 'put created disk image in FILE',
42 metavar='FILE')
43 self.settings.bytesize(['size'],
44 'create a disk image of size SIZE (%default)',
45 metavar='SIZE',
46 default='1G')
47 self.settings.bytesize(['bootsize'],
48 'create boot partition of size SIZE (%default)',
49 metavar='BOOTSIZE',
50 default='0%')
51 self.settings.string(['boottype'],
52 'specify file system type for /boot/',
53 default='ext2')
54 self.settings.string(['foreign'],
55 'set up foreign debootstrap environment using provided program (ie binfmt handler)')
56 self.settings.string(['variant'],
57 'select debootstrap variant it not using the default')
58 self.settings.boolean(
59 ['extlinux'],
60 'install extlinux?',
61 default=True)
62 self.settings.string(['tarball'], "tar up the disk's contents in FILE",
63 metavar='FILE')
64 self.settings.string(['mirror'],
65 'use MIRROR as package source (%default)',
66 metavar='URL',
67 default='http://cdn.debian.net/debian/')
68 self.settings.string(['arch'], 'architecture to use (%default)',
69 metavar='ARCH',
70 default=default_arch)
71 self.settings.string(['distribution'],
72 'release to use (%default)',
73 metavar='NAME',
74 default='stable')
75 self.settings.string_list(['package'], 'install PACKAGE onto system')
76 self.settings.string_list(['custom-package'],
77 'install package in DEB file onto system '
78 '(not from mirror)',
79 metavar='DEB')
80 self.settings.boolean(['no-kernel'], 'do not install a linux package')
81 self.settings.boolean(['enable-dhcp'], 'enable DHCP on eth0')
82 self.settings.string(['root-password'], 'set root password',
83 metavar='PASSWORD')
84 self.settings.boolean(['lock-root-password'],
85 'lock root account so they cannot login?')
86 self.settings.string(['customize'],
87 'run SCRIPT after setting up system',
88 metavar='SCRIPT')
89 self.settings.string(['hostname'],
90 'set name to HOSTNAME (%default)',
91 metavar='HOSTNAME',
92 default='debian')
93 self.settings.string_list(['user'],
94 'create USER with PASSWORD',
95 metavar='USER/PASSWORD')
96 self.settings.boolean(['serial-console'],
97 'configure image to use a serial console')
98 self.settings.string(['serial-console-command'],
99 'command to manage the serial console, appended '
100 'to /etc/inittab (%default)',
101 metavar='COMMAND',
102 default='/sbin/getty -L ttyS0 115200 vt100')
103 self.settings.boolean(['sudo'],
104 'install sudo, and if user is created, add them '
105 'to sudo group')
106 self.settings.string(['owner'],
107 'the user who will own the image when the build '
108 'is complete.')
109 self.settings.boolean(['squash'],
110 'use squashfs on the final image.')
111 self.settings.boolean(['configure-apt'],
112 'Create an apt source based on the distribution '
113 'and mirror selected.')
114 self.settings.boolean(['mbr'],
115 'Run install-mbr (no longer done by default)')
116 self.settings.boolean(['grub'],
117 'Install and configure grub2 - disables '
118 'extlinux.')
119
120 def process_args(self, args):
121 if not self.settings['image'] and not self.settings['tarball']:
122 raise cliapp.AppException('You must give disk image filename, '
123 'or tarball filename')
124 if self.settings['image'] and not self.settings['size']:
125 raise cliapp.AppException('If disk image is specified, '
126 'You must give image size.')
127
128 self.remove_dirs = []
129 self.mount_points = []
130
131 try:
132 rootdev = None
133 roottype = 'ext4'
134 bootdev = None
135 boottype = None
136 rootdir = None
137 if self.settings['image']:
138 self.create_empty_image()
139 self.partition_image()
140 if self.settings['mbr']:
141 self.install_mbr()
142 (rootdev, bootdev) = self.setup_kpartx()
143 self.mkfs(rootdev, type=roottype)
144 rootdir = self.mount(rootdev)
145 if bootdev:
146 if self.settings['boottype']:
147 boottype = self.settings['boottype']
148 else:
149 boottype = 'ext2'
150 self.mkfs(bootdev, type=boottype)
151 bootdir = '%s/%s' % (rootdir, 'boot/')
152 os.mkdir(bootdir)
153 bootdir = self.mount(bootdev, bootdir)
154 else:
155 rootdir = self.mkdtemp()
156 self.debootstrap(rootdir)
157 self.set_hostname(rootdir)
158 self.create_fstab(rootdir, rootdev, roottype, bootdev, boottype)
159 self.install_debs(rootdir)
160 self.cleanup_apt_cache(rootdir)
161 self.set_root_password(rootdir)
162 self.create_users(rootdir)
163 self.remove_udev_persistent_rules(rootdir)
164 self.setup_networking(rootdir)
165 if self.settings['configure-apt']:
166 self.configure_apt(rootdir)
167 self.customize(rootdir)
168 if self.settings['image']:
169 if self.settings['grub']:
170 self.install_grub2(rootdev, rootdir)
171 elif self.settings['extlinux']:
172 self.install_extlinux(rootdev, rootdir)
173 self.append_serial_console(rootdir)
174 self.optimize_image(rootdir)
175 if self.settings['squash']:
176 self.squash()
177
178 if self.settings['foreign']:
179 os.unlink('%s/usr/bin/%s' %
180 (rootdir, os.path.basename(self.settings['foreign'])))
181
182 if self.settings['tarball']:
183 self.create_tarball(rootdir)
184
185 if self.settings['owner']:
186 self.chown(rootdir)
187 except BaseException, e:
188 self.message('EEEK! Something bad happened...')
189 if rootdir:
190 db_log = os.path.join(rootdir, 'debootstrap', 'debootstrap.log')
191 if os.path.exists(db_log):
192 shutil.copy(db_log, os.getcwd())
193 self.message(e)
194 self.cleanup_system()
195 raise
196 else:
197 self.cleanup_system()
198
199 def message(self, msg):
200 logging.info(msg)
201 if self.settings['verbose']:
202 print msg
203
204 def runcmd(self, argv, stdin='', ignore_fail=False, **kwargs):
205 logging.debug('runcmd: %s %s' % (argv, kwargs))
206 p = subprocess.Popen(argv, stdin=subprocess.PIPE,
207 stdout=subprocess.PIPE, stderr=subprocess.PIPE,
208 **kwargs)
209 out, err = p.communicate(stdin)
210 if p.returncode != 0:
211 msg = 'command failed: %s\n%s\n%s' % (argv, out, err)
212 logging.error(msg)
213 if not ignore_fail:
214 raise cliapp.AppException(msg)
215 return out
216
217 def mkdtemp(self):
218 dirname = tempfile.mkdtemp()
219 self.remove_dirs.append(dirname)
220 logging.debug('mkdir %s' % dirname)
221 return dirname
222
223 def mount(self, device, path=None):
224 if not path:
225 mount_point = self.mkdtemp()
226 else:
227 mount_point = path
228 self.message('Mounting %s on %s' % (device, mount_point))
229 self.runcmd(['mount', device, mount_point])
230 self.mount_points.append(mount_point)
231 logging.debug('mounted %s on %s' % (device, mount_point))
232 return mount_point
233
234 def create_empty_image(self):
235 self.message('Creating disk image')
236 self.runcmd(['qemu-img', 'create', '-f', 'raw',
237 self.settings['image'],
238 str(self.settings['size'])])
239
240 def partition_image(self):
241 self.message('Creating partitions')
242 self.runcmd(['parted', '-s', self.settings['image'],
243 'mklabel', 'msdos'])
244 if self.settings['bootsize'] and self.settings['bootsize'] is not '0%':
245 bootsize = str(self.settings['bootsize'] / (1024 * 1024))
246 self.runcmd(['parted', '-s', self.settings['image'],
247 'mkpart', 'primary', 'fat16', '0', bootsize])
248 else:
249 bootsize = '0%'
250 self.runcmd(['parted', '-s', self.settings['image'],
251 'mkpart', 'primary', bootsize, '100%'])
252 self.runcmd(['parted', '-s', self.settings['image'],
253 'set', '1', 'boot', 'on'])
254
255 def install_mbr(self):
256 if os.path.exists("/sbin/install-mbr"):
257 self.message('Installing MBR')
258 self.runcmd(['install-mbr', self.settings['image']])
259
260 def setup_kpartx(self):
261 out = self.runcmd(['kpartx', '-avs', self.settings['image']])
262 if self.settings['bootsize']:
263 bootindex = 0
264 rootindex = 1
265 parts = 2
266 else:
267 rootindex = 0
268 parts = 1
269 boot = None
270 devices = [line.split()[2]
271 for line in out.splitlines()
272 if line.startswith('add map ')]
273 if len(devices) != parts:
274 raise cliapp.AppException('Surprising number of partitions')
275 root = '/dev/mapper/%s' % devices[rootindex]
276 if self.settings['bootsize']:
277 boot = '/dev/mapper/%s' % devices[bootindex]
278 return (root, boot)
279
280 def mkfs(self, device, type):
281 self.message('Creating filesystem %s' % type)
282 self.runcmd(['mkfs', '-t', type, device])
283
284 def debootstrap(self, rootdir):
285 self.message('Debootstrapping')
286
287 if self.settings['foreign']:
288 necessary_packages = []
289 else:
290 necessary_packages = ['acpid']
291
292 if self.settings['grub']:
293 necessary_packages.append('grub2')
294
295 include = self.settings['package']
296
297 if not self.settings['no-kernel']:
298 if self.settings['arch'] == 'i386':
299 kernel_arch = '486'
300 else:
301 kernel_arch = self.settings['arch']
302 kernel_image = 'linux-image-%s' % kernel_arch
303 include.append(kernel_image)
304
305 if self.settings['sudo'] and 'sudo' not in include:
306 include.append('sudo')
307
308 args = ['debootstrap', '--arch=%s' % self.settings['arch']]
309 args.append(
310 '--include=%s' % ','.join(necessary_packages + include))
311 if self.settings['foreign']:
312 args.append('--foreign')
313 if self.settings['variant']:
314 args.append('--variant')
315 args.append(self.settings['variant'])
316 args += [self.settings['distribution'],
317 rootdir, self.settings['mirror']]
318 logging.debug(" ".join(args))
319 self.runcmd(args)
320 if self.settings['foreign']:
321 # First copy the binfmt handler over
322 shutil.copy(self.settings['foreign'], '%s/usr/bin/' % rootdir)
323 # Next, run the package install scripts etc.
324 self.message('Running debootstrap second stage')
325 self.runcmd(['chroot', rootdir,
326 '/debootstrap/debootstrap', '--second-stage'])
327
328 def set_hostname(self, rootdir):
329 hostname = self.settings['hostname']
330 with open(os.path.join(rootdir, 'etc', 'hostname'), 'w') as f:
331 f.write('%s\n' % hostname)
332
333 etc_hosts = os.path.join(rootdir, 'etc', 'hosts')
334 try:
335 with open(etc_hosts, 'r') as f:
336 data = f.read()
337 with open(etc_hosts, 'w') as f:
338 for line in data.splitlines():
339 if line.startswith('127.0.0.1'):
340 line += ' %s' % hostname
341 f.write('%s\n' % line)
342 except IOError, e:
343 pass
344
345 def create_fstab(self, rootdir, rootdev, roottype, bootdev, boottype):
346 def fsuuid(device):
347 out = self.runcmd(['blkid', '-c', '/dev/null', '-o', 'value',
348 '-s', 'UUID', device])
349 return out.splitlines()[0].strip()
350
351 if rootdev:
352 rootdevstr = 'UUID=%s' % fsuuid(rootdev)
353 else:
354 rootdevstr = '/dev/sda1'
355
356 if bootdev:
357 bootdevstr = 'UUID=%s' % fsuuid(bootdev)
358 else:
359 bootdevstr = None
360
361 fstab = os.path.join(rootdir, 'etc', 'fstab')
362 with open(fstab, 'w') as f:
363 f.write('proc /proc proc defaults 0 0\n')
364 f.write('%s / %s errors=remount-ro 0 1\n' % (rootdevstr, roottype))
365 if bootdevstr:
366 f.write('%s /boot %s errors=remount-ro 0 2\n' % (bootdevstr, boottype))
367
368 def install_debs(self, rootdir):
369 if not self.settings['custom-package']:
370 return
371 self.message('Installing custom packages')
372 tmp = os.path.join(rootdir, 'tmp', 'install_debs')
373 os.mkdir(tmp)
374 for deb in self.settings['custom-package']:
375 shutil.copy(deb, tmp)
376 filenames = [os.path.join('/tmp/install_debs', os.path.basename(deb))
377 for deb in self.settings['custom-package']]
378 out, err, exit = \
379 self.runcmd_unchecked(['chroot', rootdir, 'dpkg', '-i'] + filenames)
380 logging.debug('stdout:\n%s' % out)
381 logging.debug('stderr:\n%s' % err)
382 out = self.runcmd(['chroot', rootdir,
383 'apt-get', '-f', '--no-remove', 'install'])
384 logging.debug('stdout:\n%s' % out)
385 shutil.rmtree(tmp)
386
387 def cleanup_apt_cache(self, rootdir):
388 out = self.runcmd(['chroot', rootdir, 'apt-get', 'clean'])
389 logging.debug('stdout:\n%s' % out)
390
391 def set_root_password(self, rootdir):
392 if self.settings['root-password']:
393 self.message('Setting root password')
394 self.set_password(rootdir, 'root', self.settings['root-password'])
395 elif self.settings['lock-root-password']:
396 self.message('Locking root password')
397 self.runcmd(['chroot', rootdir, 'passwd', '-l', 'root'])
398 else:
399 self.message('Give root an empty password')
400 self.delete_password(rootdir, 'root')
401
402 def create_users(self, rootdir):
403 def create_user(user):
404 self.runcmd(['chroot', rootdir, 'adduser', '--gecos', user,
405 '--disabled-password', user])
406 if self.settings['sudo']:
407 self.runcmd(['chroot', rootdir, 'adduser', user, 'sudo'])
408
409 for userpass in self.settings['user']:
410 if '/' in userpass:
411 user, password = userpass.split('/', 1)
412 create_user(user)
413 self.set_password(rootdir, user, password)
414 else:
415 create_user(userpass)
416 self.delete_password(rootdir, userpass)
417
418 def set_password(self, rootdir, user, password):
419 encrypted = crypt.crypt(password, '..')
420 self.runcmd(['chroot', rootdir, 'usermod', '-p', encrypted, user])
421
422 def delete_password(self, rootdir, user):
423 self.runcmd(['chroot', rootdir, 'passwd', '-d', user])
424
425 def remove_udev_persistent_rules(self, rootdir):
426 self.message('Removing udev persistent cd and net rules')
427 for x in ['70-persistent-cd.rules', '70-persistent-net.rules']:
428 pathname = os.path.join(rootdir, 'etc', 'udev', 'rules.d', x)
429 if os.path.exists(pathname):
430 logging.debug('rm %s' % pathname)
431 os.remove(pathname)
432 else:
433 logging.debug('not removing non-existent %s' % pathname)
434
435 def setup_networking(self, rootdir):
436 self.message('Setting up networking')
437
438 f = open(os.path.join(rootdir, 'etc', 'network', 'interfaces'), 'w')
439 f.write('auto lo\n')
440 f.write('iface lo inet loopback\n')
441
442 if self.settings['enable-dhcp']:
443 f.write('\n')
444 f.write('auto eth0\n')
445 f.write('iface eth0 inet dhcp\n')
446
447 f.close()
448
449 def append_serial_console(self, rootdir):
450 if self.settings['serial-console']:
451 serial_command = self.settings['serial-console-command']
452 logging.debug('adding getty to serial console')
453 inittab = os.path.join(rootdir, 'etc/inittab')
454 with open(inittab, 'a') as f:
455 f.write('\nS0:23:respawn:%s\n' % serial_command)
456
457 def install_grub2(self, rootdev, rootdir):
458 self.message("Configuring grub2")
459 # rely on kpartx using consistent naming to map loop0p1 to loop0
460 install_dev = os.path.join('/dev', os.path.basename(rootdev)[:-2])
461 self.runcmd(['mount', '/dev', '-t', 'devfs', '-obind',
462 '%s' % os.path.join(rootdir, 'dev')])
463 self.runcmd(['mount', '/proc', '-t', 'proc', '-obind',
464 '%s' % os.path.join(rootdir, 'proc')])
465 self.runcmd(['mount', '/sys', '-t', 'sysfs', '-obind',
466 '%s' % os.path.join(rootdir, 'sys')])
467 try:
468 self.runcmd(['chroot', rootdir, 'update-grub'])
469 self.runcmd(['chroot', rootdir, 'grub-install', install_dev])
470 except cliapp.AppException as e:
471 self.message("Failed to configure grub2. Using extlinux.")
472 self.runcmd(['umount', os.path.join(rootdir, 'sys')])
473 self.runcmd(['umount', os.path.join(rootdir, 'proc')])
474 self.runcmd(['umount', os.path.join(rootdir, 'dev')])
475 self.install_extlinux(rootdev, rootdir)
476
477 def install_extlinux(self, rootdev, rootdir):
478 if not os.path.exists("/usr/bin/extlinux"):
479 self.message("extlinux not installed, skipping.")
480 return
481 self.message('Installing extlinux')
482
483 def find(pattern):
484 dirname = os.path.join(rootdir, 'boot')
485 basenames = os.listdir(dirname)
486 logging.debug('find: %s' % basenames)
487 for basename in basenames:
488 if re.search(pattern, basename):
489 return os.path.join('boot', basename)
490 raise cliapp.AppException('Cannot find match: %s' % pattern)
491
492 kernel_image = find('vmlinuz-.*')
493 initrd_image = find('initrd.img-.*')
494
495 out = self.runcmd(['blkid', '-c', '/dev/null', '-o', 'value',
496 '-s', 'UUID', rootdev])
497 uuid = out.splitlines()[0].strip()
498
499 conf = os.path.join(rootdir, 'extlinux.conf')
500 logging.debug('configure extlinux %s' % conf)
501 f = open(conf, 'w')
502 f.write('''
503 default linux
504 timeout 1
505
506 label linux
507 kernel %(kernel)s
508 append initrd=%(initrd)s root=UUID=%(uuid)s ro %(kserial)s
509 %(extserial)s
510 ''' % {
511 'kernel': kernel_image,
512 'initrd': initrd_image,
513 'uuid': uuid,
514 'kserial':
515 'console=ttyS0,115200' if self.settings['serial-console'] else '',
516 'extserial': 'serial 0 115200' if self.settings['serial-console'] else '',
517 })
518 f.close()
519
520 self.runcmd(['extlinux', '--install', rootdir])
521 self.runcmd(['sync'])
522 time.sleep(2)
523
524 def optimize_image(self, rootdir):
525 """
526 Filing up the image with zeros will increase its compression rate
527 """
528 zeros = os.path.join(rootdir, 'ZEROS')
529 self.runcmd_unchecked(['dd', 'if=/dev/zero', 'of=' + zeros, 'bs=1M'])
530 self.runcmd(['rm', '-f', zeros])
531
532 def squash(self):
533 """
534 Run squashfs on the image.
535 """
536 if not os.path.exists('/usr/bin/mksquashfs'):
537 logging.warning("Squash selected but mksquashfs not found!")
538 return
539 self.message("Running mksquashfs")
540 suffixed = "%s.squashfs" % self.settings['image']
541 self.runcmd(['mksquashfs', self.settings['image'],
542 suffixed,
543 '-no-progress', '-comp', 'xz'], ignore_fail=False)
544 os.unlink(self.settings['image'])
545 self.settings['image'] = suffixed
546
547 def cleanup_system(self):
548 # Clean up after any errors.
549
550 self.message('Cleaning up')
551
552 # Umount in the reverse mount order
553 if self.settings['image']:
554 for i in xrange(len(self.mount_points) - 1, -1, -1):
555 mount_point = self.mount_points[i]
556 try:
557 self.runcmd(['umount', mount_point], ignore_fail=False)
558 except cliapp.AppException:
559 logging.debug("umount failed, sleeping and trying again")
560 time.sleep(5)
561 self.runcmd(['umount', mount_point], ignore_fail=False)
562
563 self.runcmd(['kpartx', '-d', self.settings['image']], ignore_fail=True)
564
565 for dirname in self.remove_dirs:
566 shutil.rmtree(dirname)
567
568 def customize(self, rootdir):
569 script = self.settings['customize']
570 if not script:
571 return
572 if not os.path.exists(script):
573 example = os.path.join("/usr/share/vmdebootstrap/examples/", script)
574 if not os.path.exists(example):
575 self.message("Unable to find %s" % script)
576 return
577 script = example
578 self.message('Running customize script %s' % script)
579 with open('/dev/tty', 'w') as tty:
580 cliapp.runcmd([script, rootdir], stdout=tty, stderr=tty)
581
582 def create_tarball(self, rootdir):
583 # Create a tarball of the disk's contents
584 # shell out to runcmd since it more easily handles rootdir
585 self.message('Creating tarball of disk contents')
586 self.runcmd(['tar', '-cf', self.settings['tarball'], '-C', rootdir, '.'])
587
588 def chown(self, rootdir):
589 # Change image owner after completed build
590 self.message("Changing owner to %s" % self.settings["owner"])
591 subprocess.call(["chown",
592 self.settings["owner"],
593 self.settings["image"]])
594
595 def configure_apt(self, rootdir):
596 # use the distribution and mirror to create an apt source
597 self.message("Configuring apt to use distribution and mirror")
598 conf = os.path.join(rootdir, 'etc', 'apt', 'sources.list.d', 'base.list')
599 logging.debug('configure apt %s' % conf)
600 f = open(conf, 'w')
601 f.write('''
602 deb %(mirror)s %(distribution)s main
603 #deb-src %(mirror)s %(distribution)s main
604 ''' % {
605 'mirror': self.settings['mirror'],
606 'distribution': self.settings['distribution']
607 })
608 f.close()
609
610 if __name__ == '__main__':
611 VmDebootstrap(version=__version__).run()