Implement mount and unmount handling
[grml2usb.git] / grml2usb.py
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
3 """
4 grml2usb
5 ~~~~~~~~
6
7 This script installs a grml system (running system / ISO(s) to a USB device
8
9 :copyright: (c) 2009 by Michael Prokop <mika@grml.org>
10 :license: GPL v2 or any later version
11 :bugreports: http://grml.org/bugs/
12
13 TODO
14 ----
15
16 * detect old grml2usb usage and inform user about it and exit then
17   -> rename grml2usb.py to grml2usb then
18 * handling of bootloader configuration for multiple ISOs
19 * verify that the specified device is really USB (/dev/usb-sd* -> /sys/devices/*/removable_)
20 * validate partition schema? bootable flag
21 * implement missing options (--kernel, --initrd, --uninstall,...)
22 * improve error handling :)
23 * get rid of "if not dry_run" inside code/functions
24 * implement mount handling
25 * implement logic for storing information about copied files
26   -> register every single file?
27 * trap handling (like unmount devices when interrupting?)
28 * get rid of all TODOs in code :)
29 * graphical version? :)
30 """
31
32 from __future__ import with_statement
33 import os, re, subprocess, sys, tempfile
34 from optparse import OptionParser
35 from os.path import exists, join, abspath
36 from os import pathsep
37 from inspect import isroutine, isclass
38 import logging
39
40 PROG_VERSION = "0.0.1"
41
42 skip_mbr = False # By default we don't want to skip it; TODO - can we get rid of that?
43
44 # cmdline parsing
45 usage = "Usage: %prog [options] <[ISO[s] | /live/image]> </dev/ice>\n\
46 \n\
47 %prog installs a grml ISO to an USB device to be able to boot from it.\n\
48 Make sure you have at least a grml ISO or a running grml system (/live/image),\n\
49 syslinux (just run 'aptitude install syslinux' on Debian-based systems)\n\
50 and root access."
51
52 parser = OptionParser(usage=usage)
53 parser.add_option("--bootoptions", dest="bootoptions",
54                   action="store", type="string",
55                   help="use specified bootoptions as defaut")
56 parser.add_option("--bootloader-only", dest="bootloaderonly", action="store_true",
57                   help="do not copy files only but just install a bootloader")
58 parser.add_option("--copy-only", dest="copyonly", action="store_true",
59                   help="copy files only and do not install bootloader")
60 parser.add_option("--dry-run", dest="dryrun", action="store_true",
61                   help="do not actually execute any commands")
62 parser.add_option("--fat16", dest="fat16", action="store_true",
63                   help="format specified partition with FAT16")
64 parser.add_option("--force", dest="force", action="store_true",
65                   help="force any actions requiring manual interaction")
66 parser.add_option("--grub", dest="grub", action="store_true",
67                   help="install grub bootloader instead of syslinux")
68 parser.add_option("--initrd", dest="initrd", action="store", type="string",
69                   help="install specified initrd instead of the default")
70 parser.add_option("--kernel", dest="kernel", action="store", type="string",
71                   help="install specified kernel instead of the default")
72 parser.add_option("--mbr", dest="mbr", action="store_true",
73                   help="install master boot record (MBR) on the device")
74 parser.add_option("--mountpath", dest="mountpath", action="store_true",
75                   help="install system to specified mount path")
76 parser.add_option("--quiet", dest="quiet", action="store_true",
77                   help="do not output anything than errors on console")
78 parser.add_option("--squashfs", dest="squashfs", action="store", type="string",
79                   help="install specified squashfs file instead of the default")
80 parser.add_option("--uninstall", dest="uninstall", action="store_true",
81                   help="remove grml ISO files")
82 parser.add_option("--verbose", dest="verbose", action="store_true",
83                   help="enable verbose mode")
84 parser.add_option("-v", "--version", dest="version", action="store_true",
85                   help="display version and exit")
86 (options, args) = parser.parse_args()
87
88
89 def get_function_name(obj):
90     if not (isroutine(obj) or isclass(obj)):
91         obj = type(obj)
92     return obj.__module__ + '.' + obj.__name__
93
94 def execute(f, *args):
95     """Wrapper for executing a command. Either really executes
96     the command (default) or when using --dry-run commandline option
97     just displays what would be executed."""
98     # demo: execute(subprocess.Popen, (["ls", "-la"]))
99     if options.dryrun:
100         logging.debug('dry-run only: %s(%s)' % (get_function_name(f), ', '.join(map(repr, args))))
101     else:
102         return f(*args)
103
104
105 def is_exe(fpath):
106     """Check whether a given file can be executed
107
108     @fpath: full path to file
109     @return:"""
110     return os.path.exists(fpath) and os.access(fpath, os.X_OK)
111
112
113 def which(program):
114     """Check whether a given program is available in PATH
115
116     @program: name of executable"""
117     fpath, fname = os.path.split(program)
118     if fpath:
119         if is_exe(program):
120             return program
121     else:
122         for path in os.environ["PATH"].split(os.pathsep):
123             exe_file = os.path.join(path, program)
124             if is_exe(exe_file):
125                 return exe_file
126
127     return None
128
129
130 def search_file(filename, search_path='/bin' + pathsep + '/usr/bin'):
131     """Given a search path, find file"""
132     file_found = 0
133     paths = search_path.split(pathsep)
134     for path in paths:
135         for current_dir, directories, files in os.walk(path):
136             if exists(join(current_dir, filename)):
137                 file_found = 1
138                 break
139     if file_found:
140         return abspath(join(current_dir, filename))
141     else:
142         return None
143
144
145 def check_uid_root():
146     """Check for root permissions"""
147     if not os.geteuid()==0:
148         sys.exit("Error: please run this script with uid 0 (root).")
149
150
151 def install_syslinux(device, dry_run=False):
152     # TODO
153     """Install syslinux on specified device."""
154     logging.critical("debug: syslinux %s [TODO]" % device)
155
156     # syslinux -d boot/isolinux /dev/usb-sdb1
157
158
159 def generate_grub_config(grml_flavour):
160     """Generate grub configuration for use via menu,lst"""
161
162     # TODO
163     # * install main part of configuration just *once* and append
164     #   flavour specific configuration only
165     # * what about systems using grub2 without having grub1 available?
166     # * support grub2?
167
168     grml_name = grml_flavour
169
170     return("""\
171 # misc options:
172 timeout 30
173 # color red/blue green/black
174 splashimage=/boot/grub/splash.xpm.gz
175 foreground  = 000000
176 background  = FFCC33
177
178 # define entries:
179 title %(grml_name)s  - Default boot (using 1024x768 framebuffer)
180 kernel /boot/release/%(grml_name)s/linux26 apm=power-off lang=us vga=791 quiet boot=live nomce module=%(grml_name)s
181 initrd /boot/release/%(grml_name)s/initrd.gz
182
183 # TODO: extend configuration :)
184 """ % locals())
185
186
187 def generate_isolinux_splash(grml_flavour):
188     """Generate bootsplash for isolinux/syslinux"""
189
190     # TODO
191     # * adjust last bootsplash line
192
193     grml_name = grml_flavour
194
195     return("""\
196 \ f17\f\18/boot/isolinux/logo.16
197
198 Some information and boot options available via keys F2 - F10. http://grml.org/
199 %(grml_name)s
200 """ % locals())
201
202 def generate_syslinux_config(grml_flavour):
203     """Generate configuration for use in syslinux.cfg"""
204
205     # TODO
206     # * install main part of configuration just *once* and append
207     #   flavour specific configuration only
208     # * unify isolinux and syslinux setup ("INCLUDE /boot/...")
209     #   as far as possible
210
211     grml_name = grml_flavour
212
213     return("""\
214 # use this to control the bootup via a serial port
215 # SERIAL 0 9600
216 DEFAULT grml
217 TIMEOUT 300
218 PROMPT 1
219 DISPLAY /boot/isolinux/boot.msg
220 F1 /boot/isolinux/boot.msg
221 F2 /boot/isolinux/f2
222 F3 /boot/isolinux/f3
223 F4 /boot/isolinux/f4
224 F5 /boot/isolinux/f5
225 F6 /boot/isolinux/f6
226 F7 /boot/isolinux/f7
227 F8 /boot/isolinux/f8
228 F9 /boot/isolinux/f9
229 F10 /boot/isolinux/f10
230
231 LABEL grml
232 KERNEL /boot/release/%(grml_name)s/linux26
233 APPEND initrd=/boot/release/%(grml_name)s/initrd.gz apm=power-off lang=us boot=live nomce module=%(grml_name)s
234
235 # TODO: extend configuration :)
236 """ % locals())
237
238
239 def install_grub(device, dry_run=False):
240     """Install grub on specified device."""
241     logging.critical("TODO: grub-install %s"  % device)
242
243
244 def install_bootloader(partition, dry_run=False):
245     """Install bootloader on device."""
246     # Install bootloader on the device (/dev/sda),
247     # not on the partition itself (/dev/sda1)
248     if partition[-1:].isdigit():
249         device = re.match(r'(.*?)\d*$', partition).group(1)
250     else:
251         device = partition
252
253     if options.grub:
254         install_grub(device, dry_run)
255     else:
256         install_syslinux(device, dry_run)
257
258
259 def is_writeable(device):
260     """Check if the device is writeable for the current user"""
261
262     if not device:
263         return False
264         #raise Exception, "no device for checking write permissions"
265
266     if not os.path.exists(device):
267         return False
268
269     return os.access(device, os.W_OK) and os.access(device, os.R_OK)
270
271 def install_mbr(device, dry_run=False):
272     """Install a default master boot record on given device
273
274     @device: device where MBR should be installed to"""
275
276     if not is_writeable(device):
277         raise IOError, "device not writeable for user"
278
279     lilo = './lilo/lilo.static' # FIXME
280
281     if not is_exe(lilo):
282         raise Exception, "lilo executable not available."
283
284     # to support -A for extended partitions:
285     logging.debug("%s -S /dev/null -M %s ext" % (lilo, device))
286     proc = subprocess.Popen([lilo, "-S", "/dev/null", "-M", device, "ext"])
287     proc.wait()
288     if proc.returncode != 0:
289         raise Exception, "error executing lilo"
290
291     # activate partition:
292     logging.debug("%s -S /dev/null -A %s 1" % (lilo, device))
293     if not dry_run:
294         proc = subprocess.Popen([lilo, "-S", "/dev/null", "-A", device, "1"])
295         proc.wait()
296         if proc.returncode != 0:
297             raise Exception, "error executing lilo"
298
299     # lilo's mbr is broken, use the one from syslinux instead:
300     logging.debug("cat /usr/lib/syslinux/mbr.bin > %s" % device)
301     if not dry_run:
302         try:
303             # TODO use Popen instead?
304             retcode = subprocess.call("cat /usr/lib/syslinux/mbr.bin > "+ device, shell=True)
305             if retcode < 0:
306                 logging.critical("Error copying MBR to device (%s)" % retcode)
307         except OSError, error:
308             logging.critical("Execution failed:", error)
309
310
311 def mount(source, target, options):
312     """Mount specified source on given target
313
314     @source: name of device/ISO that should be mounted
315     @target: directory where the ISO should be mounted to
316     @options: mount specific options"""
317
318 #   notice: dry_run does not work here, as we have to locate files, identify flavour,...
319     logging.debug("mount %s %s %s" % (options, source, target))
320     proc = subprocess.Popen(["mount"] + list(options) + [source, target])
321     proc.wait()
322     if proc.returncode != 0:
323         raise Exception, "Error executing mount"
324
325 def unmount(directory):
326     """Unmount specified directory
327
328     @directory: directory where something is mounted on and which should be unmounted"""
329
330     logging.debug("umount %s" % directory)
331     proc = subprocess.Popen(["umount"] + [directory])
332     proc.wait()
333     if proc.returncode != 0:
334         raise Exception, "Error executing umount"
335
336
337 def check_for_vat(partition):
338     """Check whether specified partition is a valid VFAT/FAT16 filesystem
339
340     @partition: device name of partition"""
341
342     try:
343         udev_info = subprocess.Popen(["/lib/udev/vol_id", "-t",
344             partition],stdout=subprocess.PIPE, stderr=subprocess.PIPE)
345         filesystem = udev_info.communicate()[0].rstrip()
346
347         if udev_info.returncode == 2:
348             logging.critical("failed to read device %s - wrong UID / permissions?" % partition)
349             return 1
350
351         if filesystem != "vfat":
352             return 1
353
354         # TODO
355         # * check for ID_FS_VERSION=FAT16 as well?
356
357     except OSError:
358         logging.critical("Sorry, /lib/udev/vol_id not available.")
359         return 1
360
361
362 def mkdir(directory):
363     """Simple wrapper around os.makedirs to get shell mkdir -p behaviour"""
364
365     if not os.path.isdir(directory):
366         try:
367             os.makedirs(directory)
368         except OSError:
369             # just silently pass as it's just fine it the directory exists
370             pass
371
372
373 def copy_grml_files(grml_flavour, iso_mount, target, dry_run=False):
374     """Copy files from ISO on given target"""
375
376     # TODO
377     # * provide alternative search_file() if file information is stored in a config.ini file?
378     # * catch "install: .. No space left on device" & CO
379     # * abstract copy logic to make the code shorter and get rid of spaghetti ;)
380
381     logging.info("Copying files. This might take a while....")
382
383     squashfs = search_file(grml_flavour + '.squashfs', iso_mount)
384     squashfs_target = target + '/live/'
385     execute(mkdir, squashfs_target)
386
387     # use install(1) for now to make sure we can write the files afterwards as normal user as well
388     logging.debug("cp %s %s" % (squashfs, target + '/live/' + grml_flavour + '.squashfs'))
389     proc = execute(subprocess.Popen, ["install", "--mode=664", squashfs, squashfs_target + grml_flavour + ".squashfs"])
390     proc.wait()
391
392     filesystem_module = search_file('filesystem.module', iso_mount)
393     logging.debug("cp %s %s" % (filesystem_module, squashfs_target + grml_flavour + '.module'))
394     proc = execute(subprocess.Popen, ["install", "--mode=664", filesystem_module, squashfs_target + grml_flavour + '.module'])
395     proc.wait()
396
397     release_target = target + '/boot/release/' + grml_flavour
398     execute(mkdir, release_target)
399
400     kernel = search_file('linux26', iso_mount)
401     logging.debug("cp %s %s" % (kernel, release_target + '/linux26'))
402     proc = execute(subprocess.Popen, ["install", "--mode=664", kernel, release_target + '/linux26'])
403     proc.wait()
404
405     initrd = search_file('initrd.gz', iso_mount)
406     logging.debug("cp %s %s" % (initrd, release_target + '/initrd.gz'))
407     proc = execute(subprocess.Popen, ["install", "--mode=664", initrd, release_target + '/initrd.gz'])
408     proc.wait()
409
410     if not options.copyonly:
411         isolinux_target = target + '/boot/isolinux/'
412         execute(mkdir, isolinux_target)
413
414         # FIXME - Fatal: could not identify grml flavour, sorry.
415         logo = search_file('logo.16', iso_mount)
416         logging.debug("cp %s %s" % logo, isolinux_target + 'logo.16')
417         proc = execute(subprocess.Popen, ["install", "--mode=664", logo, isolinux_target + 'logo.16'])
418         proc.wait()
419
420         for ffile in 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10':
421             bootsplash = search_file(ffile, iso_mount)
422             logging.debug("cp %s %s" % (bootsplash, isolinux_target + ffile))
423             proc = execute(subprocess.Popen, ["install", "--mode=664", bootsplash, isolinux_target + ffile])
424             proc.wait()
425
426         grub_target = target + '/boot/grub/'
427         execute(mkdir, grub_target)
428
429         logging.debug("cp grub/splash.xpm.gz %s" % grub_target + 'splash.xpm.gz')
430         proc = execute(subprocess.Popen, ["install", "--mode=664", 'grub/splash.xpm.gz', grub_target + 'splash.xpm.gz'])
431         proc.wait()
432
433         logging.debug("cp grub/stage2_eltorito to %s" % grub_target + 'stage2_eltorito')
434         proc = execute(subprocess.Popen, ["install", "--mode=664", 'grub/stage2_eltorito', grub_target + 'stage2_eltorito'])
435         proc.wait()
436
437         logging.debug("Generating grub configuration %s" % grub_target + 'menu.lst')
438         if not dry_run:
439             #with open("...", "w") as f:
440             #f.write("bla bla bal")
441             grub_config_file = open(grub_target + 'menu.lst', 'w')
442             grub_config_file.write(generate_grub_config(grml_flavour))
443             grub_config_file.close( )
444
445         syslinux_target = target + '/boot/isolinux/'
446         execute(mkdir, syslinux_target)
447
448         logging.debug("Generating syslinux configuration %s" % syslinux_target + 'syslinux.cfg')
449         if not dry_run:
450             syslinux_config_file = open(syslinux_target + 'syslinux.cfg', 'w')
451             syslinux_config_file.write(generate_syslinux_config(grml_flavour))
452             syslinux_config_file.close( )
453
454         logging.debug("Generating isolinux/syslinux splash %s" % syslinux_target + 'boot.msg')
455         if not dry_run:
456             isolinux_splash = open(syslinux_target + 'boot.msg', 'w')
457             isolinux_splash.write(generate_isolinux_splash(grml_flavour))
458             isolinux_splash.close( )
459
460
461     # make sure we are sync before continuing
462     proc = subprocess.Popen(["sync"])
463     proc.wait()
464
465 def uninstall_files(device):
466     """Get rid of all grml files on specified device"""
467
468     # TODO
469     logging.critical("TODO: %s" % device)
470
471
472 def identify_grml_flavour(mountpath):
473     """Get name of grml flavour
474
475     @mountpath: path where the grml ISO is mounted to
476     @return: name of grml-flavour"""
477
478     version_file = search_file('grml-version', mountpath)
479
480     if version_file == "":
481         logging.critical("Error: could not find grml-version file.")
482         raise
483
484     try:
485         tmpfile = open(version_file, 'r')
486         grml_info = tmpfile.readline()
487         grml_flavour = re.match(r'[\w-]*', grml_info).group()
488     except TypeError:
489         raise
490     except:
491         logging.critical("Unexpected error:", sys.exc_info()[0])
492         raise
493
494     return grml_flavour
495
496 def handle_iso(iso, device):
497     """TODO
498     """
499
500     logging.info("iso = %s" % iso)
501
502     if os.path.isdir(iso):
503         logging.critical("TODO: /live/image handling not yet implemented") # TODO
504     else:
505         iso_mountpoint = tempfile.mkdtemp()
506         remove_iso_mountpoint = True
507         mount(iso, iso_mountpoint, ["-o", "loop", "-t", "iso9660"])
508
509         if os.path.isdir(device):
510             logging.debug("Specified target is a directory, not mounting therefore.")
511             device_mountpoint = device
512             remove_device_mountpoint = False
513             skip_mbr = True
514
515         else:
516             device_mountpoint = tempfile.mkdtemp()
517             remove_device_mountpoint = True
518             mount(device, device_mountpoint, "")
519
520         try:
521             grml_flavour = identify_grml_flavour(iso_mountpoint)
522             logging.info("Identified grml flavour \"%s\"." % grml_flavour)
523             copy_grml_files(grml_flavour, iso_mountpoint, device_mountpoint, dry_run=options.dryrun)
524         except TypeError:
525             logging.critical("Fatal: could not identify grml flavour, sorry.")
526             sys.exit(1)
527         finally:
528             if os.path.isdir(iso_mountpoint) and remove_iso_mountpoint:
529                 unmount(iso_mountpoint)
530                 os.rmdir(iso_mountpoint)
531             if os.path.isdir(device_mountpoint) and remove_device_mountpoint:
532                 unmount(device_mountpoint)
533                 os.rmdir(device_mountpoint)
534
535         # grml_flavour_short = grml_flavour.replace('-','')
536         # logging.debug("grml_flavour_short = %s" % grml_flavour_short)
537
538
539 def main():
540     """Main function [make pylint happy :)]"""
541
542     if options.version:
543         print os.path.basename(sys.argv[0]) + " " + PROG_VERSION
544         sys.exit(0)
545
546     if len(args) < 2:
547         parser.error("invalid usage")
548
549     if options.verbose:
550         FORMAT = "%(asctime)-15s %(message)s"
551         logging.basicConfig(level=logging.DEBUG, format=FORMAT)
552     elif options.quiet:
553         FORMAT = "Critial: %(message)s"
554         logging.basicConfig(level=logging.CRITICAL, format=FORMAT)
555     else:
556         FORMAT = "Info: %(message)s"
557         logging.basicConfig(level=logging.INFO, format=FORMAT)
558
559     if options.dryrun:
560         logging.info("Running in simulate mode as requested via option dry-run.")
561     else:
562         check_uid_root()
563
564     device = args[len(args) - 1]
565     isos = args[0:len(args) - 1]
566
567     if not which("syslinux"):
568         logging.critical('Sorry, syslinux not available. Exiting.')
569         logging.critical('Please install syslinux or consider using the --grub option.')
570         sys.exit(1)
571
572     # TODO
573     # * check for valid blockdevice, vfat and mount functions
574     # if device is not None:
575         # check_for_vat(device)
576         # mount_target(partition)
577
578     for iso in isos:
579         handle_iso(iso, device)
580
581     if options.mbr and not skip_mbr:
582         # make sure we install MBR on /dev/sdX and not /dev/sdX#
583         if device[-1:].isdigit():
584             device = re.match(r'(.*?)\d*$', device).group(1)
585
586         try:
587             install_mbr(device, dry_run=options.dryrun)
588         except IOError, error:
589             logging.critical("Execution failed:", error)
590             sys.exit(1)
591         except Exception, error:
592             logging.critical("Execution failed:", error)
593             sys.exit(1)
594
595     if options.copyonly:
596         logging.info("Not installing bootloader and its files as requested via option copyonly.")
597     else:
598         install_bootloader(device, dry_run=options.dryrun)
599
600     logging.info("Finished execution of grml2usb (%s). Have fun with your grml system." % PROG_VERSION)
601
602 if __name__ == "__main__":
603     try:
604         main()
605     except KeyboardInterrupt:
606         print "TODO: handle me! :)"
607
608 ## END OF FILE #################################################################
609 # vim:foldmethod=marker expandtab ai ft=python tw=120 fileencoding=utf-8