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