diff --git a/ChangeLog b/ChangeLog index 51cf16707d0e9e3f546e615200b3b153043544f6..262b2f1023a26ffb303d6d9718a0cf09618f0200 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,13 @@ +2013-06-21, v0.4.1 + * Fix a bug that caused an abnormal termination when trying to + determine the available users in a Windows image + +2013-06-21, v0.4 + * Populate the USERS metadata in Windows images + * Support kamaki 0.9 and synnefo 0.14 + * Change the metadata file format to json + * Support the devflow building system + 2013-05-27, v0.3 * Support media hosting FreeBSD systems * Check if remote files exist when uploading images to pithos diff --git a/devflow.conf b/devflow.conf new file mode 100644 index 0000000000000000000000000000000000000000..7c0200d27170439503f7af13192d1401f5144f1c --- /dev/null +++ b/devflow.conf @@ -0,0 +1,3 @@ +[ packages ] + [[ snf-image-creator ]] + version_file = "image_creator/version.py" diff --git a/docs/conf.py b/docs/conf.py index 2e40c86c509fb13fbf0d8871f1b19308ea6b5e77..4d729c890981aef2aae68924cdb1554bc0cf6369 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ copyright = u'2012, 2013 GRNET S.A. All rights reserved' # built documents. # # The short X.Y version. -version = '0.3' +version = '0.4.1' # The full version, including alpha/beta/rc tags. -release = '0.3' +release = '0.4.1' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/man/snf-image-creator.rst b/docs/man/snf-image-creator.rst index f55652dfebc79a2f5995bc827f64aea1b017dd2f..9d388595c62c9a8f551d8240957d68b6bf52f425 100644 --- a/docs/man/snf-image-creator.rst +++ b/docs/man/snf-image-creator.rst @@ -16,6 +16,12 @@ itself. Options ------- +-a URL, --authentication-url=URL + use this authentication URL when uploading/registering images + +-c CLOUD, --cloud=CLOUD + use this saved cloud account to authenticate against a cloud when + uploading/registering images --disable-sysprep=SYSPREP prevent SYSPREP operation from running on the input media @@ -42,27 +48,26 @@ Options dump image to FILE --public - register image with cyclades as public + register image with the storage service as public --print-sysprep print the enabled and disabled system preparation operations for this input media -r IMAGENAME, --register=IMAGENAME - register the image with cyclades as IMAGENAME + register the image with the compute service with name IMAGENAME -s, --silent output only errors -t TOKEN, --token=TOKEN - use this token when uploading/registering images to a Synnefo - deployment + use this token when uploading/registering images --tmpdir=DIR create large temporary image files under DIR -u FILENAME, --upload=FILENAME - upload the image to pithos with name FILENAME + save the image to the storage service with remote name FILENAME --version show program's version number and exit diff --git a/docs/snapshots/confirm.png b/docs/snapshots/confirm.png index 2ac9d6b1376544de8ac7827f94a29d31e701fca9..1a8614ba77f78c197dc0564d076f03741d277c96 100644 Binary files a/docs/snapshots/confirm.png and b/docs/snapshots/confirm.png differ diff --git a/docs/snapshots/main_menu.png b/docs/snapshots/main_menu.png index 536eec0296b12ec9e54327070c37155e427fbdf2..8dd8f8b89ee262993d56819fe9ccbe4ff68b3ff9 100644 Binary files a/docs/snapshots/main_menu.png and b/docs/snapshots/main_menu.png differ diff --git a/docs/snapshots/wizard.png b/docs/snapshots/wizard.png index 0e270189fedbcf353d1e8f5a2d2030b904ea32a7..cfff8c46385a3118454a9048cc3be49030c46562 100644 Binary files a/docs/snapshots/wizard.png and b/docs/snapshots/wizard.png differ diff --git a/docs/usage.rst b/docs/usage.rst index c43fb29b946a3bf8c98c8ab78ed091357f8e75e5..679b319a8ea4476262c9cc44e5461f7f1e1de59b 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -17,42 +17,50 @@ snf-image-creator receives the following options: .. code-block:: console - $ snf-image-creator --help - Usage: snf-image-creator [options] <input_media> - - Options: - --version show program's version number and exit - -h, --help show this help message and exit - -o FILE, --outfile=FILE - dump image to FILE - -f, --force overwrite output files if they exist - -s, --silent output only errors - -u FILENAME, --upload=FILENAME - upload the image to pithos with name FILENAME - -r IMAGENAME, --register=IMAGENAME - register the image with ~okeanos as IMAGENAME - -m KEY=VALUE, --metadata=KEY=VALUE - add custom KEY=VALUE metadata to the image - -t TOKEN, --token=TOKEN - use this token when uploading/registering images - [Default: None] - --print-sysprep print the available enabled and disabled system - preparation operations for this input media - --enable-sysprep=SYSPREP - run SYSPREP operation on the input media - --disable-sysprep=SYSPREP - prevent SYSPREP operation from running on the input - media - --no-sysprep don't perform any system preparation operation - --no-shrink don't shrink the image - --public register image with cyclades as public - --tmpdir=DIR create large temporary image files under DIR + $ snf-image-creator --help + Usage: snf-image-creator [options] <input_media> + + Options: + --version show program's version number and exit + -h, --help show this help message and exit + -o FILE, --outfile=FILE + dump image to FILE + -f, --force overwrite output files if they exist + -s, --silent output only errors + -u FILENAME, --upload=FILENAME + upload the image to the storage service with name FILENAME + -r IMAGENAME, --register=IMAGENAME + register the image with the compute service as IMAGENAME + -m KEY=VALUE, --metadata=KEY=VALUE + add custom KEY=VALUE metadata to the image + -t TOKEN, --token=TOKEN + use this authentication token when + uploading/registering images + -a URL, --authentication-url=URL + use this authentication URL when uploading/registering + images + -c CLOUD, --cloud=CLOUD + use this saved cloud account to authenticate against a + cloud when uploading/registering images + --print-sysprep print the enabled and disabled system preparation + operations for this input media + --enable-sysprep=SYSPREP + run SYSPREP operation on the input media + --disable-sysprep=SYSPREP + prevent SYSPREP operation from running on the input + media + --no-sysprep don't perform any system preparation operation + --no-shrink don't shrink any partition + --public register image with the compute service as public + --tmpdir=DIR create large temporary image files under DIR Most input options are self-describing. If you want to save a local copy of the image you create, provide a filename using the *-o* option. To upload the -image to *pithos+*, provide a valid authentication token using *-t* and a -filename using *-u*. If you also want to register the image with *~okeanos*, in -addition to *-u* provide a registration name using *-r*. All images are +image to the storage service of a cloud, provide valid cloud API access info +(by either using a token and a URL with *-t* and *-a* respectively, or a cloud +name with *-c*) and a remote filename using *-u*. If you also want to register +the image with the compute service of the cloud, in addition to *-u* provide a +registration name using *-r*. All images are registered as *private*. Only the user that registers the image can create VM's out of it. If you want the image to be visible by other user too, use the *--public* option. @@ -75,61 +83,62 @@ debian system, we print the following output: .. code-block:: console - $ snf-image-creator --print-sysprep debian_desktop.img - - snf-image-creator 0.1 + $ snf-image-creator --print-sysprep ubuntu.raw + snf-image-creator 0.3 ===================== - Examining source media `debian_desktop.img'... looks like an image file - Snapshotting media source... done + Examining source media `ubuntu_hd.raw' ... looks like an image file + Snapshotting media source ... done Enabling recovery proc - Launching helper VM... done - Inspecting Operating System... found a(n) debian system - Mounting the media read-only... done - + Launching helper VM (may take a while) ... done + Inspecting Operating System ... ubuntu + Mounting the media read-only ... done + Collecting image metadata ... done + Umounting the media ... done + Enabled system preparation operations: cleanup-cache: - Remove all regular files under /var/cache - + Remove all regular files under /var/cache + cleanup-log: - Empty all files under /var/log - + Empty all files under /var/log + cleanup-passwords: - Remove all passwords and lock all user accounts - + Remove all passwords and lock all user accounts + cleanup-tmp: - Remove all files under /tmp and /var/tmp - + Remove all files under /tmp and /var/tmp + cleanup-userdata: - Delete sensitive userdata - + Delete sensitive userdata + fix-acpid: - Replace acpid powerdown action scripts to immediately shutdown the - system without checking if a GUI is running. - + Replace acpid powerdown action scripts to immediately shutdown the + system without checking if a GUI is running. + remove-persistent-net-rules: - Remove udev rules that will keep network interface names persistent - after hardware changes and reboots. Those rules will be created again - the next time the image runs. - + Remove udev rules that will keep network interface names persistent + after hardware changes and reboots. Those rules will be created again + the next time the image runs. + remove-swap-entry: - Remove swap entry from /etc/fstab. If swap is the last partition - then the partition will be removed when shrinking is performed. If the - swap partition is not the last partition in the disk or if you are not - going to shrink the image you should probably disable this. - + Remove swap entry from /etc/fstab. If swap is the last partition + then the partition will be removed when shrinking is performed. If the + swap partition is not the last partition in the disk or if you are not + going to shrink the image you should probably disable this. + use-persistent-block-device-names: - Scan fstab & grub configuration files and replace all non-persistent - device references with UUIDs. - + Scan fstab & grub configuration files and replace all non-persistent + device references with UUIDs. + Disabled system preparation operations: cleanup-mail: - Remove all files under /var/mail and /var/spool/mail - + Remove all files under /var/mail and /var/spool/mail + remove-user-accounts: - Remove all user accounts with id greater than 1000 - - - cleaning up... + Remove all user accounts with id greater than 1000 + + + cleaning up ... If you want the image to have all normal user accounts and all mail files removed, you should use *--enable-sysprep* option like this: @@ -173,15 +182,15 @@ Wizard mode When *snf-mkimage* runs in *wizard* mode, the user is just asked to provide the following basic information: + * Cloud: The cloud account to use to upload and register the resulting image * Name: A short name for the image (ex. "Slackware") * Description: An one-line description for the image (ex. "Slackware Linux 14.0 with KDE") * Registration Type: Private or Public - * Account: The authentication token for an *~okeanos* account -After confirming, the image will be extracted, uploaded to *pithos+* and -registered with *~okeanos*. The user will also be given the choice to keep a -local copy of it. +After confirming, the image will be extracted, uploaded to the storage service +and registered with the compute service of the selected cloud. The user will +also be given the choice to keep a local copy of it. For most users the functionality this mode provides should be sufficient. @@ -202,11 +211,10 @@ In the *Customize* sub-menu the user can control: In the *Register* sub-menu the user can provide: - * The credentials (authentication token) to use when authenticating - to *~okeanos* - * A *pithos+* filename for the uploaded *diskdump* image - * A name for the image to use when registering it with *~okeanos*, as well as - the registration type (*private* or *public*) + * Which cloud account to use + * A filename for the uploaded *diskdump* image + * A name for the image to use when registering it with the storage service of + the cloud, as well as the registration type (*private* or *public*) By choosing the *Extract* menu entry, the user can dump the image to the local file system. Finally, if the user selects *Reset*, the system will ignore @@ -244,13 +252,13 @@ Create a 2G sparse file to host the new system: .. code-block:: console - $ truncate -s 2G ubuntu_hd.raw + $ truncate -s 2G ubuntu.raw And install the Ubuntu system on this file: .. code-block:: console - $ sudo kvm -boot d -drive file=ubuntu_hd.raw,format=raw,cache=none,if=virtio \ + $ sudo kvm -boot d -drive file=ubuntu.raw,format=raw,cache=none,if=virtio \ -m 1G -cdrom ubuntu-12.04.2-server-amd64.iso .. warning:: @@ -261,7 +269,7 @@ And install the Ubuntu system on this file: You will be able to boot your installed OS and make any changes you want (e.g. install openssh-server) using the following command:: - $ sudo kvm -m 1G -boot c -drive file=ubuntu_hd.raw,format=raw,cache=none,if=virtio + $ sudo kvm -m 1G -boot c -drive file=ubuntu.raw,format=raw,cache=none,if=virtio After you're done, you may use *snf-mkimage* as root to create and upload the image: @@ -269,20 +277,20 @@ image: .. code-block:: console $ sudo -s - $ snf-mkimage ubuntu_hd.raw + $ snf-mkimage ubuntu.raw In the first screen you will be asked to choose if you want to run the program in *Wizard* or *Expert* mode. Choose *Wizard*. .. image:: /snapshots/wizard.png -Then you will be asked to provide a name, a description, a registration type -(*private* or *public*) and the authentication token corresponding to your -*~okeanos* account. Finally, you'll be asked to confirm the provided data. +Then you will be asked to select a cloud and provide a name, a description and +a registration type (*private* or *public*). Finally, you'll be asked to +confirm the provided data. .. image:: /snapshots/confirm.png -Choosing *YES* will create and upload the image to your *~okeanos* account. +Choosing *YES* will create and upload the image to your cloud account. Limitations =========== @@ -304,13 +312,13 @@ contain primary or logical partitions. Para-virtualized drivers ------------------------ -*~Okeanos* uses the *VirtIO* framework. The disk I/O controller and the -Ethernet cards on the VM instances are para-virtualized and need special -*VirtIO* drivers. Those drivers are included in the Linux Kernel mainline since -version 2.6.25 and are shipped with all the popular Linux distributions. The -problem is that if the driver for the para-virtualized disk I/O controller is -built as module, it needs to be preloaded using an initial ramdisk, otherwise -the VM won't be able to boot. +Most synnefo deployments uses the *VirtIO* framework. The disk I/O controller +and the Ethernet cards on the VM instances are para-virtualized and need +special *VirtIO* drivers. Those drivers are included in the Linux Kernel +mainline since version 2.6.25 and are shipped with all the popular Linux +distributions. The problem is that if the driver for the para-virtualized disk +I/O controller is built as module, it needs to be preloaded using an initial +ramdisk, otherwise the VM won't be able to boot. Many popular Linux distributions, like Ubuntu and Debian, will automatically create a generic initial ramdisk file that contains many different modules, diff --git a/image_creator/__init__.py b/image_creator/__init__.py index 67775ed29ce7044660aeaf62fbdae13d86dd2b6b..06d6eccdad78229a40811e167d8cabf4249b2744 100644 --- a/image_creator/__init__.py +++ b/image_creator/__init__.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +33,10 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. -__version__ = '0.3' +"""Package for creating images to be used with Synnefo open source cloud +software. +""" + +from image_creator.version import __version__ # vim: set sta sts=4 shiftwidth=4 sw=4 et ai : diff --git a/image_creator/bundle_volume.py b/image_creator/bundle_volume.py index 18d0245e29334f63d2fd882b7f5d50e87673da41..eec084f847f58c967a68da67858b0a417f817ae2 100644 --- a/image_creator/bundle_volume.py +++ b/image_creator/bundle_volume.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +33,11 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module hosts the code that performes the host bundling operation. By +using the create_image method of the BundleVolume class the user can create an +image out of the running system. +""" + import os import re import tempfile diff --git a/image_creator/dialog_main.py b/image_creator/dialog_main.py index 024c37f4af21d35e476fb9551acfcb2134532825..0834cb44c2639291c2c0278bef07c04f47fff3a7 100644 --- a/image_creator/dialog_main.py +++ b/image_creator/dialog_main.py @@ -1,5 +1,6 @@ #!/usr/bin/env python - +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -33,6 +34,11 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module is the entrance point for the dialog-based version of the +snf-image-creator program. The main function will create a dialog where the +user is asked if he wants to use the program in expert or wizard mode. +""" + import dialog import sys import os @@ -40,6 +46,7 @@ import stat import textwrap import signal import optparse +import types from image_creator import __version__ as version from image_creator.util import FatalError @@ -157,6 +164,39 @@ def select_file(d, media): return media +def _dialog_form(self, text, height=20, width=60, form_height=15, fields=[], + **kwargs): + """Display a form box. + + fields is in the form: [(label1, item1, item_length1), ...] + """ + + cmd = ["--form", text, str(height), str(width), str(form_height)] + + label_len = 0 + for field in fields: + if len(field[0]) > label_len: + label_len = len(field[0]) + + input_len = width - label_len - 1 + + line = 1 + for field in fields: + label = field[0] + item = field[1] + item_len = field[2] + cmd.extend((label, str(line), str(1), item, str(line), + str(label_len + 1), str(input_len), str(item_len))) + line += 1 + + code, output = self._perform(*(cmd,), **kwargs) + + if not output: + return (code, []) + + return (code, output.splitlines()) + + def main(): d = dialog.Dialog(dialog="dialog") @@ -175,6 +215,10 @@ def main(): dialog._common_args_syntax["no_label"] = \ lambda string: ("--no-label", string) + # Monkey-patch pythondialog to include support for form dialog boxes + if not hasattr(dialog, 'form'): + d.form = types.MethodType(_dialog_form, d) + usage = "Usage: %prog [options] [<input_media>]" parser = optparse.OptionParser(version=version, usage=usage) parser.add_option("-l", "--logfile", type="string", dest="logfile", diff --git a/image_creator/dialog_menu.py b/image_creator/dialog_menu.py index b6d31624a7c68724db80d36d0c4f0e6f14dc107f..fd7bc4b0ede0e8516a873dbefebe6934592ba449 100644 --- a/image_creator/dialog_menu.py +++ b/image_creator/dialog_menu.py @@ -1,5 +1,5 @@ -#!/usr/bin/env python - +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -33,18 +33,23 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module implements the "expert" mode of the dialog-based version of +snf-image-creator. +""" + import os import textwrap import StringIO +import json from image_creator import __version__ as version -from image_creator.util import MD5 +from image_creator.util import MD5, FatalError from image_creator.output.dialog import GaugeOutput, InfoBoxOutput from image_creator.kamaki_wrapper import Kamaki, ClientError from image_creator.help import get_help_file from image_creator.dialog_util import SMALL_WIDTH, WIDTH, \ update_background_title, confirm_reset, confirm_exit, Reset, \ - extract_image, extract_metadata_string + extract_image, extract_metadata_string, add_cloud, edit_cloud CONFIGURATION_TASKS = [ ("Partition table manipulation", ["FixPartitionTable"], @@ -108,15 +113,15 @@ class MetadataMonitor(object): def upload_image(session): - """Upload the image to pithos+""" + """Upload the image to the storage service""" d = session["dialog"] image = session['image'] meta = session['metadata'] size = image.size if "account" not in session: - d.msgbox("You need to provide your ~okeanos credentials before you " - "can upload images to pithos+", width=SMALL_WIDTH) + d.msgbox("You need to select a valid cloud before you can upload " + "images to it", width=SMALL_WIDTH) return False while 1: @@ -144,8 +149,8 @@ def upload_image(session): overwrite.append(f) if len(overwrite) > 0: - if d.yesno("The following pithos object(s) already exist(s):\n" - "%s\nDo you want to overwrite them?" % + if d.yesno("The following storage service object(s) already " + "exist(s):\n%s\nDo you want to overwrite them?" % "\n".join(overwrite), width=WIDTH, defaultno=1): continue @@ -177,8 +182,9 @@ def upload_image(session): out.success("done") except ClientError as e: - d.msgbox("Error in pithos+ client: %s" % e.message, - title="Pithos+ Client Error", width=SMALL_WIDTH) + d.msgbox( + "Error in storage service client: %s" % e.message, + title="Storage Service Client Error", width=SMALL_WIDTH) if 'pithos_uri' in session: del session['pithos_uri'] return False @@ -187,26 +193,26 @@ def upload_image(session): finally: gauge.cleanup() - d.msgbox("Image file `%s' was successfully uploaded to pithos+" % filename, + d.msgbox("Image file `%s' was successfully uploaded" % filename, width=SMALL_WIDTH) return True def register_image(session): - """Register image with cyclades""" + """Register image with the compute service""" d = session["dialog"] is_public = False if "account" not in session: - d.msgbox("You need to provide your ~okeanos credentians before you " - "can register an images with cyclades", width=SMALL_WIDTH) + d.msgbox("You need to select a valid cloud before you " + "can register an images with it", width=SMALL_WIDTH) return False if "pithos_uri" not in session: - d.msgbox("You need to upload the image to pithos+ before you can " - "register it with cyclades", width=SMALL_WIDTH) + d.msgbox("You need to upload the image to the cloud before you can " + "register it", width=SMALL_WIDTH) return False while 1: @@ -243,14 +249,14 @@ def register_image(session): out.add(gauge) try: try: - out.output("Registering %s image with Cyclades..." % img_type) + out.output("Registering %s image with the cloud..." % img_type) kamaki = Kamaki(session['account'], out) - kamaki.register(name, session['pithos_uri'], metadata, - is_public) + result = kamaki.register(name, session['pithos_uri'], metadata, + is_public) out.success('done') # Upload metadata file out.output("Uploading metadata file...") - metastring = extract_metadata_string(session) + metastring = unicode(json.dumps(result, ensure_ascii=False)) kamaki.upload(StringIO.StringIO(metastring), size=len(metastring), remote_path="%s.meta" % session['upload']) @@ -261,39 +267,116 @@ def register_image(session): kamaki.share("%s.md5sum" % session['upload']) out.success('done') except ClientError as e: - d.msgbox("Error in pithos+ client: %s" % e.message) + d.msgbox("Error in storage service client: %s" % e.message) return False finally: out.remove(gauge) finally: gauge.cleanup() - d.msgbox("%s image `%s' was successfully registered with Cyclades as `%s'" + d.msgbox("%s image `%s' was successfully registered with the cloud as `%s'" % (img_type.title(), session['upload'], name), width=SMALL_WIDTH) return True +def modify_clouds(session): + """Modify existing cloud accounts""" + d = session['dialog'] + + while 1: + clouds = Kamaki.get_clouds() + if not len(clouds): + if not add_cloud(session): + break + continue + + choices = [] + for (name, cloud) in clouds.items(): + descr = cloud['description'] if 'description' in cloud else '' + choices.append((name, descr)) + + (code, choice) = d.menu( + "In this menu you can edit existing cloud accounts or add new " + " ones. Press <Edit> to edit an existing account or <Add> to add " + " a new one. Press <Back> or hit <ESC> when done.", height=18, + width=WIDTH, choices=choices, menu_height=10, ok_label="Edit", + extra_button=1, extra_label="Add", cancel="Back", help_button=1, + title="Clouds") + + if code in (d.DIALOG_CANCEL, d.DIALOG_ESC): + return True + elif code == d.DIALOG_OK: # Edit button + edit_cloud(session, choice) + elif code == d.DIALOG_EXTRA: # Add button + add_cloud(session) + + +def delete_clouds(session): + """Delete existing cloud accounts""" + d = session['dialog'] + + choices = [] + for (name, cloud) in Kamaki.get_clouds().items(): + descr = cloud['description'] if 'description' in cloud else '' + choices.append((name, descr, 0)) + + if len(choices) == 0: + d.msgbox("No available clouds to delete!", width=SMALL_WIDTH) + return True + + (code, to_delete) = d.checklist("Choose which cloud accounts to delete:", + choices=choices, width=WIDTH) + + if code in (d.DIALOG_CANCEL, d.DIALOG_ESC): + return False + + if not len(to_delete): + d.msgbox("Nothing selected!", width=SMALL_WIDTH) + return False + + if not d.yesno("Are you sure you want to remove the selected cloud " + "accounts?", width=WIDTH, defaultno=1): + for i in to_delete: + Kamaki.remove_cloud(i) + if 'cloud' in session and session['cloud'] == i: + del session['cloud'] + if 'account' in session: + del session['account'] + else: + return False + + d.msgbox("%d cloud accounts were deleted." % len(to_delete), + width=SMALL_WIDTH) + return True + + def kamaki_menu(session): """Show kamaki related actions""" d = session['dialog'] - default_item = "Account" + default_item = "Cloud" - if 'account' not in session: - token = Kamaki.get_token() - if token: - session['account'] = Kamaki.get_account(token) + if 'cloud' not in session: + cloud = Kamaki.get_default_cloud_name() + if cloud: + session['cloud'] = cloud + session['account'] = Kamaki.get_account(cloud) if not session['account']: del session['account'] - Kamaki.save_token('') # The token was not valid. Remove it + else: + default_item = "Add/Edit" while 1: - account = session["account"]['username'] if "account" in session else \ - "<none>" + cloud = session["cloud"] if "cloud" in session else "<none>" + if 'account' not in session and 'cloud' in session: + cloud += " <invalid>" + upload = session["upload"] if "upload" in session else "<none>" - choices = [("Account", "Change your ~okeanos account: %s" % account), - ("Upload", "Upload image to pithos+"), - ("Register", "Register the image to cyclades: %s" % upload)] + choices = [("Add/Edit", "Add/Edit cloud accounts"), + ("Delete", "Delete existing cloud accounts"), + ("Cloud", "Select cloud account to use: %s" % cloud), + ("Upload", "Upload image to the cloud"), + ("Register", "Register image with the cloud: %s" % upload)] (code, choice) = d.menu( text="Choose one of the following or press <Back> to go back.", @@ -304,26 +387,56 @@ def kamaki_menu(session): if code in (d.DIALOG_CANCEL, d.DIALOG_ESC): return False - if choice == "Account": - default_item = "Account" - (code, answer) = d.inputbox( - "Please provide your ~okeanos authentication token:", - init=session["account"]['auth_token'] if "account" in session - else '', width=WIDTH) + if choice == "Add/Edit": + if modify_clouds(session): + default_item = "Cloud" + elif choice == "Delete": + if delete_clouds(session): + if len(Kamaki.get_clouds()): + default_item = "Cloud" + else: + default_time = "Add/Edit" + else: + default_time = "Delete" + elif choice == "Cloud": + default_item = "Cloud" + clouds = Kamaki.get_clouds() + if not len(clouds): + d.msgbox("No clouds available. Please add a new cloud!", + width=SMALL_WIDTH) + default_item = "Add/Edit" + continue + + if 'cloud' not in session: + session['cloud'] = clouds.keys()[0] + + choices = [] + for name, info in clouds.items(): + default = 1 if session['cloud'] == name else 0 + descr = info['description'] if 'description' in info else "" + choices.append((name, descr, default)) + + (code, answer) = d.radiolist("Please select a cloud:", + width=WIDTH, choices=choices) if code in (d.DIALOG_CANCEL, d.DIALOG_ESC): continue - if len(answer) == 0 and "account" in session: - del session["account"] else: - token = answer.strip() - session['account'] = Kamaki.get_account(token) + session['account'] = Kamaki.get_account(answer) + + if session['account'] is None: # invalid account + if not d.yesno("The cloud %s' is not valid! Would you " + "like to edit it?" % answer, width=WIDTH): + if edit_cloud(session, answer): + session['account'] = Kamaki.get_account(answer) + Kamaki.set_default_cloud(answer) + if session['account'] is not None: - Kamaki.save_token(token) + session['cloud'] = answer + Kamaki.set_default_cloud(answer) default_item = "Upload" else: del session['account'] - d.msgbox("The token you provided is not valid!", - width=SMALL_WIDTH) + del session['cloud'] elif choice == "Upload": if upload_image(session): default_item = "Register" @@ -568,30 +681,19 @@ def sysprep(session): try: image.out.add(infobox) try: - image.mount(readonly=False) - try: - err = "Unable to execute the system preparation " \ - "tasks. Couldn't mount the media%s." - title = "System Preparation" - if not image.mounted: - d.msgbox(err % "", title=title, width=SMALL_WIDTH) - return - elif image.mounted_ro: - d.msgbox(err % " read-write", title=title, - width=SMALL_WIDTH) - return - - # The checksum is invalid. We have mounted the image rw - if 'checksum' in session: - del session['checksum'] - - # Monitor the metadata changes during syspreps - with MetadataMonitor(session, image.os.meta): + # The checksum is invalid. We have mounted the image rw + if 'checksum' in session: + del session['checksum'] + + # Monitor the metadata changes during syspreps + with MetadataMonitor(session, image.os.meta): + try: image.os.do_sysprep() infobox.finalize() - - finally: - image.umount() + except FatalError as e: + title = "System Preparation" + d.msgbox("System Preparation failed: %s" % e, + title=title, width=SMALL_WIDTH) finally: image.out.remove(infobox) finally: @@ -675,8 +777,8 @@ def main_menu(session): update_background_title(session) - choices = [("Customize", "Customize image & ~okeanos deployment options"), - ("Register", "Register image to ~okeanos"), + choices = [("Customize", "Customize image & cloud deployment options"), + ("Register", "Register image to a cloud"), ("Extract", "Dump image to local file system"), ("Reset", "Reset everything and start over again"), ("Help", "Get help for using snf-image-creator")] @@ -690,7 +792,7 @@ def main_menu(session): text="Choose one of the following or press <Exit> to exit.", width=WIDTH, choices=choices, cancel="Exit", height=13, default_item=default_item, menu_height=len(choices), - title="Image Creator for ~okeanos (snf-image-creator version %s)" % + title="Image Creator for synnefo (snf-image-creator version %s)" % version) if code in (d.DIALOG_CANCEL, d.DIALOG_ESC): diff --git a/image_creator/dialog_util.py b/image_creator/dialog_util.py index 046ee3ee1505fbc138513960a16f26395b9005c9..ec5be5ff8998bf0f1f432c16ab02043fc0a0572f 100644 --- a/image_creator/dialog_util.py +++ b/image_creator/dialog_util.py @@ -1,5 +1,5 @@ -#!/usr/bin/env python - +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -33,9 +33,16 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""Module providing useful functions for the dialog-based version of +snf-image-creator. +""" + import os +import re +import json from image_creator.output.dialog import GaugeOutput from image_creator.util import MD5 +from image_creator.kamaki_wrapper import Kamaki SMALL_WIDTH = 60 WIDTH = 70 @@ -78,12 +85,14 @@ class Reset(Exception): def extract_metadata_string(session): """Convert image metadata to text""" - metadata = ['%s=%s' % (k, v) for (k, v) in session['metadata'].items()] - + metadata = {} + metadata.update(session['metadata']) if 'task_metadata' in session: - metadata.extend("%s=yes" % m for m in session['task_metadata']) + for key in session['task_metadata']: + metadata[key] = 'yes' - return '\n'.join(metadata) + '\n' + return unicode(json.dumps({'properties': metadata, + 'disk-format': 'diskdump'}, ensure_ascii=False)) def extract_image(session): @@ -167,4 +176,113 @@ def extract_image(session): return True + +def _check_cloud(session, name, description, url, token): + """Checks if the provided info for a cloud are valid""" + d = session['dialog'] + regexp = re.compile('^[a-zA-Z0-9_]+$') + + if not re.match(regexp, name): + d.msgbox("Allowed characters for name: [a-zA-Z0-9_]", width=WIDTH) + return False + + if len(url) == 0: + d.msgbox("Url cannot be empty!", width=WIDTH) + return False + + if len(token) == 0: + d.msgbox("Token cannot be empty!", width=WIDTH) + return False + + if Kamaki.create_account(url, token) is None: + d.msgbox("The cloud info you provided is not valid. Please check the " + "Authentication URL and the token values again!", width=WIDTH) + return False + + return True + + +def add_cloud(session): + """Add a new cloud account""" + + d = session['dialog'] + + name = "" + description = "" + url = "" + token = "" + + while 1: + fields = [ + ("Name:", name, 60), + ("Description (optional): ", description, 80), + ("Authentication URL: ", url, 200), + ("Token:", token, 100)] + + (code, output) = d.form("Add a new cloud account:", height=13, + width=WIDTH, form_height=4, fields=fields) + + if code in (d.DIALOG_CANCEL, d.DIALOG_ESC): + return False + + name, description, url, token = output + + name = name.strip() + description = description.strip() + url = url.strip() + token = token.strip() + + if _check_cloud(session, name, description, url, token): + if name in Kamaki.get_clouds().keys(): + d.msgbox("A cloud with name `%s' already exists. If you want " + "to edit the existing cloud account, use the edit " + "menu." % name, width=WIDTH) + else: + Kamaki.save_cloud(name, url, token, description) + break + + continue + + return True + + +def edit_cloud(session, name): + """Edit a cloud account""" + + info = Kamaki.get_cloud_by_name(name) + + assert info, "Cloud: `%s' does not exist" % name + + description = info['description'] if 'description' in info else "" + url = info['url'] if 'url' in info else "" + token = info['token'] if 'token' in info else "" + + d = session['dialog'] + + while 1: + fields = [ + ("Description (optional): ", description, 80), + ("Authentication URL: ", url, 200), + ("Token:", token, 100)] + + (code, output) = d.form("Edit cloud account: `%s'" % name, height=13, + width=WIDTH, form_height=3, fields=fields) + + if code in (d.DIALOG_CANCEL, d.DIALOG_ESC): + return False + + description, url, token = output + + description = description.strip() + url = url.strip() + token = token.strip() + + if _check_cloud(session, name, description, url, token): + Kamaki.save_cloud(name, url, token, description) + break + + continue + + return True + # vim: set sta sts=4 shiftwidth=4 sw=4 et ai : diff --git a/image_creator/dialog_wizard.py b/image_creator/dialog_wizard.py index 30dc7c622b10dbe704b853e54527e241a97040ff..8abcfed6e90dbb718572bc6080e4fcaf194ec42d 100644 --- a/image_creator/dialog_wizard.py +++ b/image_creator/dialog_wizard.py @@ -1,5 +1,5 @@ -#!/usr/bin/env python - +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -33,15 +33,22 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module implements the "wizard" mode of the dialog-based version of +snf-image-creator. +""" + import time import StringIO +import json from image_creator.kamaki_wrapper import Kamaki, ClientError from image_creator.util import MD5, FatalError from image_creator.output.cli import OutputWthProgress -from image_creator.dialog_util import extract_image, update_background_title +from image_creator.dialog_util import extract_image, update_background_title, \ + add_cloud, edit_cloud PAGE_WIDTH = 70 +PAGE_HEIGHT = 10 class WizardExit(Exception): @@ -49,8 +56,8 @@ class WizardExit(Exception): pass -class WizardInvalidData(Exception): - """Exception triggered when the user provided data are invalid""" +class WizardReloadPage(Exception): + """Exception that reloads the last WizardPage""" pass @@ -76,20 +83,22 @@ class Wizard: idx = 0 while True: try: - idx += self.pages[idx].run(self.session, idx, len(self.pages)) + total = len(self.pages) + title = "(%d/%d) %s" % (idx + 1, total, self.pages[idx].title) + idx += self.pages[idx].run(self.session, title) except WizardExit: return False - except WizardInvalidData: + except WizardReloadPage: continue if idx >= len(self.pages): - msg = "All necessary information has been gathered:\n\n" + text = "All necessary information has been gathered:\n\n" for page in self.pages: - msg += " * %s\n" % page.info - msg += "\nContinue with the image creation process?" + text += " * %s\n" % page.info + text += "\nContinue with the image creation process?" ret = self.d.yesno( - msg, width=PAGE_WIDTH, height=8 + len(self.pages), + text, width=PAGE_WIDTH, height=8 + len(self.pages), ok_label="Yes", cancel="Back", extra_button=1, extra_label="Quit", title="Confirmation") @@ -109,14 +118,26 @@ class WizardPage(object): NEXT = 1 PREV = -1 - def __init__(self, **kargs): + def __init__(self, name, display_name, text, **kargs): + self.name = name + self.display_name = display_name + self.text = text + + self.title = kargs['title'] if 'title' in kargs else "" + self.default = kargs['default'] if 'default' in kargs else "" + self.extra = kargs['extra'] if 'extra' in kargs else None + self.extra_label = \ + kargs['extra_label'] if 'extra_label' in kargs else 'Extra' + + self.info = "%s: <none>" % self.display_name + validate = kargs['validate'] if 'validate' in kargs else lambda x: x setattr(self, "validate", validate) display = kargs['display'] if 'display' in kargs else lambda x: x setattr(self, "display", display) - def run(self, session, index, total): + def run(self, session, title): """Display this wizard page This function is used by the wizard program when accessing a page. @@ -124,123 +145,175 @@ class WizardPage(object): raise NotImplementedError -class WizardRadioListPage(WizardPage): - """Represent a Radio List in a wizard""" - def __init__(self, name, printable, message, choices, **kargs): - super(WizardRadioListPage, self).__init__(**kargs) - self.name = name - self.printable = printable - self.message = message +class WizardPageWthChoices(WizardPage): + """Represents a Wizard Page that allows the user to select something from + a list of choices. + + The available choices are created by a function passed to the class through + the choices variable. If the choices function returns an empty list, a + fallback funtion is executed if available. + """ + def __init__(self, name, display_name, text, choices, **kargs): + super(WizardPageWthChoices, self).__init__(name, display_name, text, + **kargs) self.choices = choices - self.title = kargs['title'] if 'title' in kargs else '' - self.default = kargs['default'] if 'default' in kargs else "" + self.fallback = kargs['fallback'] if 'fallback' in kargs else None + + +class WizardRadioListPage(WizardPageWthChoices): + """Represent a Radio List in a wizard""" - def run(self, session, index, total): + def run(self, session, title): d = session['dialog'] w = session['wizard'] choices = [] - for i in range(len(self.choices)): - default = 1 if self.choices[i][0] == self.default else 0 - choices.append((self.choices[i][0], self.choices[i][1], default)) + for choice in self.choices(): + default = 1 if choice[0] == self.default else 0 + choices.append((choice[0], choice[1], default)) (code, answer) = d.radiolist( - self.message, height=10, width=PAGE_WIDTH, ok_label="Next", - cancel="Back", choices=choices, - title="(%d/%d) %s" % (index + 1, total, self.title)) + self.text, width=PAGE_WIDTH, ok_label="Next", cancel="Back", + choices=choices, height=PAGE_HEIGHT, title=title) if code in (d.DIALOG_CANCEL, d.DIALOG_ESC): return self.PREV w[self.name] = self.validate(answer) self.default = answer - self.info = "%s: %s" % (self.printable, self.display(w[self.name])) + self.info = "%s: %s" % (self.display_name, self.display(w[self.name])) return self.NEXT class WizardInputPage(WizardPage): """Represents an input field in a wizard""" - def __init__(self, name, printable, message, **kargs): - super(WizardInputPage, self).__init__(**kargs) - self.name = name - self.printable = printable - self.message = message - self.info = "%s: <none>" % self.printable - self.title = kargs['title'] if 'title' in kargs else '' - self.init = kargs['init'] if 'init' in kargs else '' - def run(self, session, index, total): + def run(self, session, title): d = session['dialog'] w = session['wizard'] (code, answer) = d.inputbox( - self.message, init=self.init, width=PAGE_WIDTH, ok_label="Next", - cancel="Back", title="(%d/%d) %s" % (index + 1, total, self.title)) + self.text, init=self.default, width=PAGE_WIDTH, ok_label="Next", + cancel="Back", height=PAGE_HEIGHT, title=title) if code in (d.DIALOG_CANCEL, d.DIALOG_ESC): return self.PREV value = answer.strip() - self.init = value + self.default = value w[self.name] = self.validate(value) - self.info = "%s: %s" % (self.printable, self.display(w[self.name])) + self.info = "%s: %s" % (self.display_name, self.display(w[self.name])) + + return self.NEXT + + +class WizardMenuPage(WizardPageWthChoices): + """Represents a menu dialog with available choices in a wizard""" + + def run(self, session, title): + d = session['dialog'] + w = session['wizard'] + + extra_button = 1 if self.extra else 0 + + choices = self.choices() + + if len(choices) == 0: + assert self.fallback, "Zero choices and no fallback" + if self.fallback(): + raise WizardReloadPage + else: + return self.PREV + + default_item = self.default if self.default else choices[0][0] + + (code, choice) = d.menu( + self.text, width=PAGE_WIDTH, ok_label="Next", cancel="Back", + title=title, choices=choices, height=PAGE_HEIGHT, + default_item=default_item, extra_label=self.extra_label, + extra_button=extra_button) + + if code in (d.DIALOG_CANCEL, d.DIALOG_ESC): + return self.PREV + elif code == d.DIALOG_EXTRA: + self.extra() + raise WizardReloadPage + + self.default = choice + w[self.name] = self.validate(choice) + self.info = "%s: %s" % (self.display_name, self.display(w[self.name])) return self.NEXT def start_wizard(session): """Run the image creation wizard""" - init_token = Kamaki.get_token() - if init_token is None: - init_token = "" distro = session['image'].distro ostype = session['image'].ostype + + def cloud_choices(): + choices = [] + for (name, cloud) in Kamaki.get_clouds().items(): + descr = cloud['description'] if 'description' in cloud else '' + choices.append((name, descr)) + + return choices + + def cloud_add(): + return add_cloud(session) + + def cloud_none_available(): + if not session['dialog'].yesno( + "No available clouds found. Would you like to add one now?", + width=PAGE_WIDTH, defaultno=0): + return add_cloud(session) + return False + + def cloud_validate(cloud): + if not Kamaki.get_account(cloud): + if not session['dialog'].yesno( + "The cloud you have selected is not valid! Would you " + "like to edit it now?", width=PAGE_WIDTH, defaultno=0): + if edit_cloud(session, cloud): + return cloud + + raise WizardInvalidData + + return cloud + + cloud = WizardMenuPage( + "Cloud", "Cloud", + "Please select a cloud account or press <Add> to add a new one:", + choices=cloud_choices, extra_label="Add", extra=cloud_add, + title="Clouds", validate=cloud_validate, fallback=cloud_none_available) + name = WizardInputPage( "ImageName", "Image Name", "Please provide a name for the image:", - title="Image Name", init=ostype if distro == "unknown" else distro) + title="Image Name", default=ostype if distro == "unknown" else distro) descr = WizardInputPage( "ImageDescription", "Image Description", "Please provide a description for the image:", - title="Image Description", init=session['metadata']['DESCRIPTION'] if - 'DESCRIPTION' in session['metadata'] else '') + title="Image Description", default=session['metadata']['DESCRIPTION'] + if 'DESCRIPTION' in session['metadata'] else '') + + def registration_choices(): + return [("Private", "Image is accessible only by this user"), + ("Public", "Everyone can create VMs from this image")] registration = WizardRadioListPage( "ImageRegistration", "Registration Type", - "Please provide a registration type:", - [("Private", "Image is accessible only by this user"), - ("Public", "Everyone can create VMs from this image")], + "Please provide a registration type:", registration_choices, title="Registration Type", default="Private") - def validate_account(token): - """Check if a token is valid""" - d = session['dialog'] - - if len(token) == 0: - d.msgbox("The token cannot be empty", width=PAGE_WIDTH) - raise WizardInvalidData - - account = Kamaki.get_account(token) - if account is None: - d.msgbox("The token you provided in not valid!", width=PAGE_WIDTH) - raise WizardInvalidData - - return account - - account = WizardInputPage( - "Account", "Account", - "Please provide your ~okeanos authentication token:", - title="~okeanos account", init=init_token, validate=validate_account, - display=lambda account: account['username']) - w = Wizard(session) + w.add_page(cloud) w.add_page(name) w.add_page(descr) w.add_page(registration) - w.add_page(account) if w.run(): create_image(session) @@ -256,9 +329,6 @@ def create_image(session): image = session['image'] wizard = session['wizard'] - # Save Kamaki credentials - Kamaki.save_token(wizard['Account']['auth_token']) - with_progress = OutputWthProgress(True) out = image.out out.add(with_progress) @@ -266,16 +336,8 @@ def create_image(session): out.clear() #Sysprep - image.mount(False) - err_msg = "Unable to execute the system preparation tasks." - if not image.mounted: - raise FatalError("%s Couldn't mount the media." % err_msg) - elif image.mounted_ro: - raise FatalError("%s Couldn't mount the media read-write." - % err_msg) image.os.do_sysprep() metadata = image.os.meta - image.umount() #Shrink size = image.shrink() @@ -289,29 +351,22 @@ def create_image(session): md5 = MD5(out) session['checksum'] = md5.compute(image.device, size) - #Metadata - metastring = '\n'.join( - ['%s=%s' % (key, value) for (key, value) in metadata.items()]) - metastring += '\n' - out.output() try: - out.output("Uploading image to pithos:") - kamaki = Kamaki(wizard['Account'], out) + out.output("Uploading image to the cloud:") + account = Kamaki.get_account(wizard['Cloud']) + assert account, "Cloud: %s is not valid" % wizard['Cloud'] + kamaki = Kamaki(account, out) name = "%s-%s.diskdump" % (wizard['ImageName'], time.strftime("%Y%m%d%H%M")) pithos_file = "" with open(image.device, 'rb') as f: pithos_file = kamaki.upload(f, size, name, - "(1/4) Calculating block hashes", - "(2/4) Uploading missing blocks") + "(1/3) Calculating block hashes", + "(2/3) Uploading missing blocks") - out.output("(3/4) Uploading metadata file ...", False) - kamaki.upload(StringIO.StringIO(metastring), size=len(metastring), - remote_path="%s.%s" % (name, 'meta')) - out.success('done') - out.output("(4/4) Uploading md5sum file ...", False) + out.output("(3/3) Uploading md5sum file ...", False) md5sumstr = '%s %s\n' % (session['checksum'], name) kamaki.upload(StringIO.StringIO(md5sumstr), size=len(md5sumstr), remote_path="%s.%s" % (name, 'md5sum')) @@ -320,11 +375,17 @@ def create_image(session): is_public = True if wizard['ImageRegistration'] == "Public" else \ False - out.output('Registering %s image with ~okeanos ...' % + out.output('Registering %s image with the cloud ...' % wizard['ImageRegistration'].lower(), False) - kamaki.register(wizard['ImageName'], pithos_file, metadata, - is_public) + result = kamaki.register(wizard['ImageName'], pithos_file, + metadata, is_public) + out.success('done') + out.output("Uploading metadata file ...", False) + metastring = unicode(json.dumps(result, ensure_ascii=False)) + kamaki.upload(StringIO.StringIO(metastring), size=len(metastring), + remote_path="%s.%s" % (name, 'meta')) out.success('done') + if is_public: out.output("Sharing md5sum file ...", False) kamaki.share("%s.md5sum" % name) @@ -336,14 +397,17 @@ def create_image(session): out.output() except ClientError as e: - raise FatalError("Pithos client: %d %s" % (e.status, e.message)) + raise FatalError("Storage service client: %d %s" % + (e.status, e.message)) finally: out.remove(with_progress) - msg = "The %s image was successfully uploaded and registered with " \ - "~okeanos. Would you like to keep a local copy of the image?" \ - % wizard['ImageRegistration'].lower() - if not d.yesno(msg, width=PAGE_WIDTH): + text = "The %s image was successfully uploaded to the storage service " \ + "and registered with the compute service of %s. Would you like " \ + "to keep a local copy?" % \ + (wizard['Cloud'], wizard['ImageRegistration'].lower()) + + if not d.yesno(text, width=PAGE_WIDTH): extract_image(session) # vim: set sta sts=4 shiftwidth=4 sw=4 et ai : diff --git a/image_creator/disk.py b/image_creator/disk.py index 38372c52fc3f155ca34232d133fabd88178f0f07..76a53aa4102a9054a459aa7214c3108786d7365c 100644 --- a/image_creator/disk.py +++ b/image_creator/disk.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +33,8 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""Module hosting the Disk class.""" + from image_creator.util import get_command from image_creator.util import try_fail_repeat from image_creator.util import free_space @@ -50,7 +54,26 @@ losetup = get_command('losetup') blockdev = get_command('blockdev') -TMP_CANDIDATES = ['/var/tmp', os.path.expanduser('~'), '/mnt'] +def get_tmp_dir(default=None): + """Check tmp directory candidates and return the one with the most + available space. + """ + if default is not None: + return default + + TMP_CANDIDATES = ['/var/tmp', os.path.expanduser('~'), '/mnt'] + + space = map(free_space, TMP_CANDIDATES) + + max_idx = 0 + max_val = space[0] + for i, val in zip(range(len(space)), space): + if val > max_val: + max_val = val + max_idx = i + + # Return the candidate path with more available space + return TMP_CANDIDATES[max_idx] class Disk(object): @@ -71,29 +94,10 @@ class Disk(object): self.out = output self.meta = {} self.tmp = tempfile.mkdtemp(prefix='.snf_image_creator.', - dir=self._get_tmp_dir(tmp)) + dir=get_tmp_dir(tmp)) self._add_cleanup(shutil.rmtree, self.tmp) - def _get_tmp_dir(self, default=None): - """Check tmp directory candidates and return the one with the most - available space. - """ - if default is not None: - return default - - space = map(free_space, TMP_CANDIDATES) - - max_idx = 0 - max_val = space[0] - for i, val in zip(range(len(space)), space): - if val > max_val: - max_val = val - max_idx = i - - # Return the candidate path with more available space - return TMP_CANDIDATES[max_idx] - def _add_cleanup(self, job, *args): """Add a new job in the cleanup list""" self._cleanup_jobs.append((job, args)) diff --git a/image_creator/gpt.py b/image_creator/gpt.py index 968a918e5c5013272b3ad5062e317abad7c9be20..82ae09ed3b7d00b75b0ffc0d52cb677e60257de7 100644 --- a/image_creator/gpt.py +++ b/image_creator/gpt.py @@ -1,3 +1,6 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +34,8 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module provides the code for handling GUID partition tables""" + import struct import sys import uuid diff --git a/image_creator/help/__init__.py b/image_creator/help/__init__.py index faaf6976adefbeedcca765ceeb94b0d54dda794a..7f78d35bbf6c7c0f69769526769701199ade36f5 100644 --- a/image_creator/help/__init__.py +++ b/image_creator/help/__init__.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,11 +33,14 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This package hosts the help files of the programe.""" + import sys import os def get_help_file(name): + """Returns the full path of a helpfile""" dirname = os.path.dirname(sys.modules[__name__].__file__) return "%s%s%s.rst" % (dirname, os.sep, name) diff --git a/image_creator/help/image_properties.rst b/image_creator/help/image_properties.rst index 64fbb8729e3fb23f501c4c71083f1a00f1c358f0..8e65f2af9cb4e4fb741f60d447aeb16b9744eea0 100644 --- a/image_creator/help/image_properties.rst +++ b/image_creator/help/image_properties.rst @@ -11,11 +11,11 @@ Properties used during image deployment This is a space-seperated list of users, whose password will be reset during deployment. - SWAP=<n>:<size> - If this property is present, cyclades will create a swap - partition with given size at the end of the instance's disk. + If this property is present, a swap partition with given + size will be created at the end of the instance's disk. This property only makes sense for Linux images. -Properties used by the ~okeanos User Interface +Properties used by the synnefo User Interface ---------------------------------------------- - OS The value of this property is used to associate the image diff --git a/image_creator/image.py b/image_creator/image.py index de287568d697464b93f37c4df719ac37e53e979e..b27c1ebcd5af51c9aea36157d235eb19b02edeff 100644 --- a/image_creator/image.py +++ b/image_creator/image.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2013 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -53,8 +55,6 @@ class Image(object): self.progress_bar = None self.guestfs_device = None self.size = 0 - self.mounted = False - self.mounted_ro = False self.g = guestfs.GuestFS() self.g.add_drive_opts(self.device, readonly=0, format="raw") @@ -120,19 +120,10 @@ class Image(object): if not self.guestfs_enabled: self.enable() - if not self.mounted: - do_unmount = True - self.mount(readonly=True) - else: - do_unmount = False + cls = os_cls(self.distro, self.ostype) + self._os = cls(self.root, self.g, self.out) - try: - cls = os_cls(self.distro, self.ostype) - self._os = cls(self.root, self.g, self.out) - - finally: - if do_unmount: - self.umount() + self._os.collect_metadata() return self._os @@ -156,70 +147,6 @@ class Image(object): # # self.progressbar.goto((position * 100) // total) - def mount(self, readonly=False): - """Mount all disk partitions in a correct order.""" - - msg = "Mounting the media%s ..." % (" read-only" if readonly else "") - self.out.output(msg, False) - - #If something goes wrong when mounting rw, remount the filesystem ro - remount_ro = False - rw_mpoints = ('/', '/etc', '/root', '/home', '/var') - - # Sort the keys to mount the fs in a correct order. - # / should be mounted befor /boot, etc - def compare(a, b): - if len(a[0]) > len(b[0]): - return 1 - elif len(a[0]) == len(b[0]): - return 0 - else: - return -1 - mps = self.g.inspect_get_mountpoints(self.root) - mps.sort(compare) - - mopts = 'ro' if readonly else 'rw' - for mp, dev in mps: - if self.ostype == 'freebsd': - # libguestfs can't handle correct freebsd partitions on GUID - # Partition Table. We have to do the translation to linux - # device names ourselves - m = re.match('^/dev/((?:ada)|(?:vtbd))(\d+)p(\d+)$', dev) - if m: - m2 = int(m.group(2)) - m3 = int(m.group(3)) - dev = '/dev/sd%c%d' % (chr(ord('a') + m2), m3) - try: - self.g.mount_options(mopts, dev, mp) - except RuntimeError as msg: - if self.ostype == 'freebsd': - freebsd_mopts = "ufstype=ufs2,%s" % mopts - try: - self.g.mount_vfs(freebsd_mopts, 'ufs', dev, mp) - except RuntimeError as msg: - if readonly is False and mp in rw_mpoints: - remount_ro = True - break - elif readonly is False and mp in rw_mpoints: - remount_ro = True - break - else: - self.out.warn("%s (ignored)" % msg) - if remount_ro: - self.out.warn("Unable to mount %s read-write. " - "Remounting everything read-only..." % mp) - self.umount() - self.mount(True) - else: - self.mounted = True - self.mounted_ro = readonly - self.out.success("done") - - def umount(self): - """Umount all mounted filesystems.""" - self.g.umount_all() - self.mounted = False - def _last_partition(self): """Return the last partition of the image disk""" if self.meta['PARTITION_TABLE'] not in 'msdos' 'gpt': diff --git a/image_creator/kamaki_wrapper.py b/image_creator/kamaki_wrapper.py index e2012d27c84228bb794b3cdea7b554b5a50e5f2c..c515fa65764bc18557747e0b13bdcc157840e180 100644 --- a/image_creator/kamaki_wrapper.py +++ b/image_creator/kamaki_wrapper.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +33,11 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This modules provides the interface for working with the ./kamaki library. +The library is used to upload images to and register them with a Synnefo +deployment. +""" + from os.path import basename from kamaki.cli.config import Config @@ -40,51 +47,101 @@ from kamaki.clients.pithos import PithosClient from kamaki.clients.astakos import AstakosClient -class Kamaki(object): +config = Config() + +class Kamaki(object): + """Wrapper class for the ./kamaki library""" CONTAINER = "images" @staticmethod - def get_token(): - """Get the saved token""" - config = Config() - return config.get('global', 'token') + def get_default_cloud_name(): + """Returns the name of the default cloud""" + clouds = config.keys('cloud') + default = config.get('global', 'default_cloud') + if not default: + return clouds[0] if len(clouds) else "" + return default if default in clouds else "" @staticmethod - def save_token(token): - """Save this token to the configuration file""" - config = Config() - config.set('global', 'token', token) + def set_default_cloud(name): + """Sets a cloud account as default""" + config.set('global', 'default_cloud', name) config.write() @staticmethod - def get_account(token): - """Return the account corresponding to this token""" - config = Config() - astakos = AstakosClient(config.get('user', 'url'), token) + def get_clouds(): + """Returns the list of available clouds""" + names = config.keys('cloud') + + clouds = {} + for name in names: + clouds[name] = config.get('cloud', name) + + return clouds + + @staticmethod + def get_cloud_by_name(name): + """Returns a dict with cloud info""" + return config.get('cloud', name) + + @staticmethod + def save_cloud(name, url, token, description=""): + """Save a new cloud account""" + cloud = {'url': url, 'token': token} + if len(description): + cloud['description'] = description + config.set('cloud', name, cloud) + + # Make the saved cloud the default one + config.set('global', 'default_cloud', name) + config.write() + + @staticmethod + def remove_cloud(name): + """Deletes an existing cloud from the Kamaki configuration file""" + config.remove_option('cloud', name) + config.write() + + @staticmethod + def create_account(url, token): + """Given a valid (URL, tokens) pair this method returns an Astakos + client instance + """ + client = AstakosClient(url, token) try: - account = astakos.info() - except ClientError as e: - if e.status == 401: # Unauthorized: invalid token - return None - else: - raise - return account + client.authenticate() + except ClientError: + return None + + return client + + @staticmethod + def get_account(cloud_name): + """Given a saved cloud name this method returns an Astakos client + instance + """ + cloud = config.get('cloud', cloud_name) + assert cloud, "cloud: `%s' does not exist" % cloud_name + assert 'url' in cloud, "url attr is missing in %s" % cloud_name + assert 'token' in cloud, "token attr is missing in %s" % cloud_name + + return Kamaki.create_account(cloud['url'], cloud['token']) def __init__(self, account, output): """Create a Kamaki instance""" self.account = account self.out = output - config = Config() - - pithos_url = config.get('file', 'url') - self.pithos_client = PithosClient( - pithos_url, self.account['auth_token'], self.account['uuid'], + self.pithos = PithosClient( + self.account.get_service_endpoints('object-store')['publicURL'], + self.account.token, + self.account.user_info()['id'], self.CONTAINER) - image_url = config.get('image', 'url') - self.image_client = ImageClient(image_url, self.account['auth_token']) + self.image = ImageClient( + self.account.get_service_endpoints('image')['publicURL'], + self.account.token) def upload(self, file_obj, size=None, remote_path=None, hp=None, up=None): """Upload a file to pithos""" @@ -92,7 +149,7 @@ class Kamaki(object): path = basename(file_obj.name) if remote_path is None else remote_path try: - self.pithos_client.create_container(self.CONTAINER) + self.pithos.create_container(self.CONTAINER) except ClientError as e: if e.status != 202: # Ignore container already exists errors raise e @@ -100,14 +157,13 @@ class Kamaki(object): hash_cb = self.out.progress_generator(hp) if hp is not None else None upload_cb = self.out.progress_generator(up) if up is not None else None - self.pithos_client.upload_object(path, file_obj, size, hash_cb, - upload_cb) + self.pithos.upload_object(path, file_obj, size, hash_cb, upload_cb) - return "pithos://%s/%s/%s" % (self.account['uuid'], self.CONTAINER, - path) + return "pithos://%s/%s/%s" % (self.account.user_info()['id'], + self.CONTAINER, path) def register(self, name, location, metadata, public=False): - """Register an image to ~okeanos""" + """Register an image with cyclades""" # Convert all metadata to strings str_metadata = {} @@ -115,18 +171,18 @@ class Kamaki(object): str_metadata[str(key)] = str(value) is_public = 'true' if public else 'false' params = {'is_public': is_public, 'disk_format': 'diskdump'} - self.image_client.register(name, location, params, str_metadata) + return self.image.register(name, location, params, str_metadata) def share(self, location): """Share this file with all the users""" - self.pithos_client.set_object_sharing(location, "*") + self.pithos.set_object_sharing(location, "*") def object_exists(self, location): """Check if an object exists in pythos""" try: - self.pithos_client.get_object_info(location) + self.pithos.get_object_info(location) except ClientError as e: if e.status == 404: # Object not found error return False diff --git a/image_creator/main.py b/image_creator/main.py index 10fc0b5e50818b71e7186a9f615425154e80ccf6..bff95d964fcf18cdebb843ea6c42ab7c671ff5dd 100644 --- a/image_creator/main.py +++ b/image_creator/main.py @@ -1,5 +1,6 @@ #!/usr/bin/env python - +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -33,6 +34,10 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module is the entrance point for the non-interactive version of the +snf-image-creator program. +""" + from image_creator import __version__ as version from image_creator.disk import Disk from image_creator.util import FatalError, MD5 @@ -44,6 +49,7 @@ import os import optparse import StringIO import signal +import json def check_writable_dir(option, opt_str, value, parser): @@ -77,12 +83,12 @@ def parse_options(input_args): parser.add_option("-u", "--upload", dest="upload", type="string", default=False, - help="upload the image to pithos with name FILENAME", + help="upload the image to the cloud with name FILENAME", metavar="FILENAME") parser.add_option("-r", "--register", dest="register", type="string", default=False, - help="register the image with ~okeanos as IMAGENAME", + help="register the image with a cloud as IMAGENAME", metavar="IMAGENAME") parser.add_option("-m", "--metadata", dest="metadata", default=[], @@ -93,6 +99,15 @@ def parse_options(input_args): default=None, help="use this authentication token when " "uploading/registering images") + parser.add_option("-a", "--authentication-url", dest="url", type="string", + default=None, help="use this authentication URL when " + "uploading/registering images") + + parser.add_option("-c", "--cloud", dest="cloud", type="string", + default=None, help="use this saved cloud account to " + "authenticate against a cloud when " + "uploading/registering images") + parser.add_option("--print-sysprep", dest="print_sysprep", default=False, help="print the enabled and disabled system preparation " "operations for this input media", action="store_true") @@ -114,7 +129,7 @@ def parse_options(input_args): help="don't shrink any partition", action="store_false") parser.add_option("--public", dest="public", default=False, - help="register image with cyclades as public", + help="register image with the cloud as public", action="store_true") parser.add_option("--tmpdir", dest="tmp", type="string", default=None, @@ -133,10 +148,13 @@ def parse_options(input_args): if options.register and not options.upload: raise FatalError("You also need to set -u when -r option is set") - if options.upload and options.token is None: - raise FatalError( - "Image uploading cannot be performed. " - "No authentication token is specified. Use -t to set a token") + if options.upload and (options.token is None or options.url is None) and \ + options.cloud is None: + + err = "You need to either specify an authentication URL and token " \ + "pair or an available cloud name." + + raise FatalError("Image uploading cannot be performed. %s" % err) if options.tmp is not None and not os.path.isdir(options.tmp): raise FatalError("The directory `%s' specified with --tmpdir is not " @@ -184,13 +202,28 @@ def image_creator(): raise FatalError("Output file `%s' exists " "(use --force to overwrite it)." % filename) - # Check if the authentication token is valid. The earlier the better - if options.token is not None: + # Check if the authentication info is valid. The earlier the better + if options.token is not None and options.url is not None: try: - account = Kamaki.get_account(options.token) + account = Kamaki.create_account(options.url, options.token) if account is None: - raise FatalError("The authentication token you provided is not" - " valid!") + raise FatalError("The authentication token and/or URL you " + "provided is not valid!") + else: + kamaki = Kamaki(account, out) + except ClientError as e: + raise FatalError("Astakos client: %d %s" % (e.status, e.message)) + elif options.cloud: + avail_clouds = Kamaki.get_clouds() + if options.cloud not in avail_clouds.keys(): + raise FatalError( + "Cloud: `%s' does not exist.\n\nAvailable clouds:\n\n\t%s\n" + % (options.cloud, "\n\t".join(avail_clouds.keys()))) + try: + account = Kamaki.get_account(options.cloud) + if account is None: + raise FatalError( + "Cloud: `$s' exists but is not valid!" % options.cloud) else: kamaki = Kamaki(account, out) except ClientError as e: @@ -198,15 +231,16 @@ def image_creator(): if options.upload and not options.force: if kamaki.object_exists(options.upload): - raise FatalError("Remote pithos object `%s' exists " + raise FatalError("Remote storage service object: `%s' exists " "(use --force to overwrite it)." % options.upload) if kamaki.object_exists("%s.md5sum" % options.upload): - raise FatalError("Remote pithos object `%s.md5sum' exists " - "(use --force to overwrite it)." % options.upload) + raise FatalError("Remote storage service object: `%s.md5sum' " + "exists (use --force to overwrite it)." % + options.upload) if options.register and not options.force: if kamaki.object_exists("%s.meta" % options.upload): - raise FatalError("Remote pithos object `%s.meta' exists " + raise FatalError("Remote storage service object `%s.meta' exists " "(use --force to overwrite it)." % options.upload) disk = Disk(options.source, out, options.tmp) @@ -221,36 +255,23 @@ def image_creator(): image = disk.get_image(snapshot) - # If no customization is to be done, the image should be mounted ro - ro = (not (options.sysprep or options.shrink) or options.print_sysprep) - image.mount(ro) - try: - for sysprep in options.disabled_syspreps: - image.os.disable_sysprep(image.os.get_sysprep_by_name(sysprep)) + for sysprep in options.disabled_syspreps: + image.os.disable_sysprep(image.os.get_sysprep_by_name(sysprep)) - for sysprep in options.enabled_syspreps: - image.os.enable_sysprep(image.os.get_sysprep_by_name(sysprep)) + for sysprep in options.enabled_syspreps: + image.os.enable_sysprep(image.os.get_sysprep_by_name(sysprep)) - if options.print_sysprep: - image.os.print_syspreps() - out.output() + if options.print_sysprep: + image.os.print_syspreps() + out.output() - if options.outfile is None and not options.upload: - return 0 + if options.outfile is None and not options.upload: + return 0 - if options.sysprep: - err_msg = "Unable to perform the system preparation tasks. " \ - "Couldn't mount the media%s. Use --no-sysprep if you " \ - "don't won't to perform any system preparation task." - if not image.mounted: - raise FatalError(err_msg % "") - elif image.mounted_ro: - raise FatalError(err_msg % " read-write") - image.os.do_sysprep() + if options.sysprep: + image.os.do_sysprep() - metadata = image.os.meta - finally: - image.umount() + metadata = image.os.meta size = options.shrink and image.shrink() or image.size metadata.update(image.meta) @@ -261,9 +282,9 @@ def image_creator(): md5 = MD5(out) checksum = md5.compute(image.device, size) - metastring = '\n'.join( - ['%s=%s' % (key, value) for (key, value) in metadata.items()]) - metastring += '\n' + metastring = unicode(json.dumps( + {'properties': metadata, + 'disk-format': 'diskdump'}, ensure_ascii=False)) if options.outfile is not None: image.dump(options.outfile) @@ -286,7 +307,7 @@ def image_creator(): try: uploaded_obj = "" if options.upload: - out.output("Uploading image to pithos:") + out.output("Uploading image to the storage service:") with open(snapshot, 'rb') as f: uploaded_obj = kamaki.upload( f, size, options.upload, @@ -303,12 +324,13 @@ def image_creator(): if options.register: img_type = 'public' if options.public else 'private' - out.output('Registering %s image with ~okeanos ...' % img_type, - False) - kamaki.register(options.register, uploaded_obj, metadata, - options.public) + out.output('Registering %s image with the compute service ...' + % img_type, False) + result = kamaki.register(options.register, uploaded_obj, + metadata, options.public) out.success('done') out.output("Uploading metadata file ...", False) + metastring = unicode(json.dumps(result, ensure_ascii=False)) kamaki.upload(StringIO.StringIO(metastring), size=len(metastring), remote_path="%s.%s" % (options.upload, 'meta')) @@ -323,7 +345,7 @@ def image_creator(): out.output() except ClientError as e: - raise FatalError("Pithos client: %d %s" % (e.status, e.message)) + raise FatalError("Service client: %d %s" % (e.status, e.message)) finally: out.output('cleaning up ...') diff --git a/image_creator/os_type/__init__.py b/image_creator/os_type/__init__.py index cea08a32a8ac43575f4c0e167a858674ca5e8fc7..ec4f20dffcda845b1beade073d09441396cb9955 100644 --- a/image_creator/os_type/__init__.py +++ b/image_creator/os_type/__init__.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +33,10 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This package provides various classes for preparing different Operating +Systems for image creation. +""" + from image_creator.util import FatalError import textwrap @@ -77,27 +83,31 @@ class OSBase(object): self.root = rootdev self.g = ghandler self.out = output - - # Collect metadata about the OS self.meta = {} - self.meta['ROOT_PARTITION'] = "%d" % self.g.part_to_partnum(self.root) - self.meta['OSFAMILY'] = self.g.inspect_get_type(self.root) - self.meta['OS'] = self.g.inspect_get_distro(self.root) - if self.meta['OS'] == "unknown": - self.meta['OS'] = self.meta['OSFAMILY'] - self.meta['DESCRIPTION'] = self.g.inspect_get_product_name(self.root) - def _is_sysprep(self, obj): - return getattr(obj, 'sysprep', False) and callable(obj) + def collect_metadata(self): + """Collect metadata about the OS""" + try: + if not self.mount(readonly=True): + raise FatalError("Unable to mount the media read-only") - def list_syspreps(self): + self.out.output('Collecting image metadata ...', False) + self._do_collect_metadata() + self.out.success('done') + finally: + self.umount() + + self.out.output() + def list_syspreps(self): + """Returns a list of sysprep objects""" objs = [getattr(self, name) for name in dir(self) if not name.startswith('_')] return [x for x in objs if self._is_sysprep(x) and x.executed is False] def sysprep_info(self, obj): + """Returns information about a sysprep object""" assert self._is_sysprep(obj), "Object is not a sysprep" return (obj.__name__.replace('_', '-'), textwrap.dedent(obj.__doc__)) @@ -157,17 +167,69 @@ class OSBase(object): descr = wrapper.fill(textwrap.dedent(sysprep.__doc__)) self.out.output(' %s:\n%s\n' % (name, descr)) + def do_sysprep(self): + """Prepare system for image creation.""" + + try: + if not self.mount(readonly=False): + raise FatalError("Unable to mount the media read-write") + + self.out.output('Preparing system for image creation:') + + tasks = self.list_syspreps() + enabled = filter(lambda x: x.enabled, tasks) + + size = len(enabled) + cnt = 0 + for task in enabled: + cnt += 1 + self.out.output(('(%d/%d)' % (cnt, size)).ljust(7), False) + task() + setattr(task.im_func, 'executed', True) + finally: + self.umount() + + self.out.output() + + def mount(self, readonly=False): + """Mount image.""" + + if getattr(self, "mounted", False): + return True + + mount_type = 'read-only' if readonly else 'read-write' + self.out.output("Mounting the media %s ..." % mount_type, False) + + if not self._do_mount(readonly): + return False + + self.mounted = True + self.out.success('done') + return True + + def umount(self): + """Umount all mounted filesystems.""" + + self.out.output("Umounting the media ...", False) + self.g.umount_all() + self.mounted = False + self.out.success('done') + + def _is_sysprep(self, obj): + """Checks if an object is a sysprep""" + return getattr(obj, 'sysprep', False) and callable(obj) + @add_prefix - def ls(self, directory): + def _ls(self, directory): """List the name of all files under a directory""" return self.g.ls(directory) @add_prefix - def find(self, directory): + def _find(self, directory): """List the name of all files recursively under a directory""" return self.g.find(directory) - def foreach_file(self, directory, action, **kargs): + def _foreach_file(self, directory, action, **kargs): """Perform an action recursively on all files under a directory. The following options are allowed: @@ -204,26 +266,28 @@ class OSBase(object): continue if has_ftype(f, 'd'): - self.foreach_file(full_path, action, **kargs) + self._foreach_file(full_path, action, **kargs) if has_ftype(f, ftype): action(full_path) - def do_sysprep(self): - """Prepere system for image creation.""" - - self.out.output('Preparing system for image creation:') + def _do_collect_metadata(self): + """helper method for collect_metadata""" + self.meta['ROOT_PARTITION'] = "%d" % self.g.part_to_partnum(self.root) + self.meta['OSFAMILY'] = self.g.inspect_get_type(self.root) + self.meta['OS'] = self.g.inspect_get_distro(self.root) + if self.meta['OS'] == "unknown": + self.meta['OS'] = self.meta['OSFAMILY'] + self.meta['DESCRIPTION'] = self.g.inspect_get_product_name(self.root) - tasks = self.list_syspreps() - enabled = filter(lambda x: x.enabled, tasks) + def _do_mount(self, readonly): + """helper method for mount""" + try: + self.g.mount_options('ro' if readonly else 'rw', self.root, '/') + except RuntimeError as msg: + self.out.warn("unable to mount the root partition: %s" % msg) + return False - size = len(enabled) - cnt = 0 - for task in enabled: - cnt += 1 - self.out.output(('(%d/%d)' % (cnt, size)).ljust(7), False) - task() - setattr(task.im_func, 'executed', True) - self.out.output() + return True # vim: set sta sts=4 shiftwidth=4 sw=4 et ai : diff --git a/image_creator/os_type/freebsd.py b/image_creator/os_type/freebsd.py index d0ed78b20b1e6e6ac31c3174d0573e75f643a7d3..02bd8dc8ae2a9747cc810ba447bb6b1f9ce5adda 100644 --- a/image_creator/os_type/freebsd.py +++ b/image_creator/os_type/freebsd.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +33,8 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module hosts OS-specific code for FreeBSD.""" + from image_creator.os_type.unix import Unix, sysprep import re @@ -41,6 +45,37 @@ class Freebsd(Unix): def __init__(self, rootdev, ghandler, output): super(Freebsd, self).__init__(rootdev, ghandler, output) + @sysprep() + def cleanup_password(self, print_header=True): + """Remove all passwords and lock all user accounts""" + + if print_header: + self.out.output("Cleaning up passwords & locking all user " + "accounts") + + master_passwd = [] + + for line in self.g.cat('/etc/master.passwd').splitlines(): + + # Check for empty or comment lines + if len(line.split('#')[0]) == 0: + master_passwd.append(line) + continue + + fields = line.split(':') + if fields[1] not in ('*', '!'): + fields[1] = '!' + + master_passwd.append(":".join(fields)) + + self.g.write('/etc/master.passwd', "\n".join(master_passwd) + '\n') + + # Make sure no one can login on the system + self.g.rm_rf('/etc/spwd.db') + + def _do_collect_metadata(self): + """Collect metadata about the OS""" + super(Freebsd, self)._do_collect_metadata() self.meta["USERS"] = " ".join(self._get_passworded_users()) #The original product name key is long and ugly @@ -53,6 +88,7 @@ class Freebsd(Unix): del self.meta['USERS'] def _get_passworded_users(self): + """Returns a list of non-locked user accounts""" users = [] regexp = re.compile( '^([^:]+):((?:![^:]+)|(?:[^!*][^:]+)|):(?:[^:]*:){7}(?:[^:]*)' @@ -72,32 +108,32 @@ class Freebsd(Unix): return users - @sysprep() - def cleanup_password(self, print_header=True): - """Remove all passwords and lock all user accounts""" - - if print_header: - self.out.output("Cleaning up passwords & locking all user " - "accounts") - - master_passwd = [] - - for line in self.g.cat('/etc/master.passwd').splitlines(): - - # Check for empty or comment lines - if len(line.split('#')[0]) == 0: - master_passwd.append(line) - continue - - fields = line.split(':') - if fields[1] not in ('*', '!'): - fields[1] = '!' - - master_passwd.append(":".join(fields)) - - self.g.write('/etc/master.passwd', "\n".join(master_passwd) + '\n') - - # Make sure no one can login on the system - self.g.rm_rf('/etc/spwd.db') + def _do_mount(self, readonly): + """Mount partitions in the correct order""" + + critical_mpoints = ('/', '/etc', '/root', '/home', '/var') + + # libguestfs can't handle correct freebsd partitions on a GUID + # Partition Table. We have to do the translation to linux device names + # ourselves + guid_device = re.compile('^/dev/((?:ada)|(?:vtbd))(\d+)p(\d+)$') + + mopts = "ufstype=ufs2,%s" % ('ro' if readonly else 'rw') + for mp, dev in self._mountpoints(): + match = guid_device.match(dev) + if match: + group2 = int(match.group(2)) + group3 = int(match.group(3)) + dev = '/dev/sd%c%d' % (chr(ord('a') + group2), group3) + try: + self.g.mount_vfs(mopts, 'ufs', dev, mp) + except RuntimeError as msg: + if mp in critical_mpoints: + self.out.warn('unable to mount %s. Reason: %s' % (mp, msg)) + return False + else: + self.out.warn('%s (ignored)' % msg) + + return True # vim: set sta sts=4 shiftwidth=4 sw=4 et ai : diff --git a/image_creator/os_type/hurd.py b/image_creator/os_type/hurd.py index 64c4a67d564f8a9611e1971b028c5c96e5c20df7..2f3e82c968fac6de179c7f9aeb80a48ec7969525 100644 --- a/image_creator/os_type/hurd.py +++ b/image_creator/os_type/hurd.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +33,8 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module hosts OS-specific code for GNU Hurd.""" + from image_creator.os_type.unix import Unix diff --git a/image_creator/os_type/linux.py b/image_creator/os_type/linux.py index 86638c02092b1bd4bb1ea39a5b47ee6455c25801..47243368f1fadcf41178341813818dbf6c52878e 100644 --- a/image_creator/os_type/linux.py +++ b/image_creator/os_type/linux.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +33,8 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module hosts OS-specific code for Linux""" + from image_creator.os_type.unix import Unix, sysprep import re @@ -44,42 +48,6 @@ class Linux(Unix): self._uuid = dict() self._persistent = re.compile('/dev/[hsv]d[a-z][1-9]*') - self.meta["USERS"] = " ".join(self._get_passworded_users()) - - # Delete the USERS metadata if empty - if not len(self.meta['USERS']): - self.out.warn("No passworded users found!") - del self.meta['USERS'] - - def _get_passworded_users(self): - users = [] - regexp = re.compile('(\S+):((?:!\S+)|(?:[^!*]\S+)|):(?:\S*:){6}') - - for line in self.g.cat('/etc/shadow').splitlines(): - match = regexp.match(line) - if not match: - continue - - user, passwd = match.groups() - if len(passwd) > 0 and passwd[0] == '!': - self.out.warn("Ignoring locked %s account." % user) - else: - users.append(user) - - return users - - def is_persistent(self, dev): - return not self._persistent.match(dev) - - def get_uuid(self, dev): - if dev in self._uuid: - return self._uuid[dev] - - uuid = self.g.vfs_uuid(dev) - assert len(uuid) - self._uuid[dev] = uuid - return uuid - @sysprep(enabled=False) def remove_user_accounts(self, print_header=True): """Remove all user accounts with id greater than 1000""" @@ -175,21 +143,21 @@ class Linux(Unix): event_exp = re.compile('event=(.+)', re.I) action_exp = re.compile('action=(.+)', re.I) - for f in self.g.readdir(events_dir): - if f['ftyp'] != 'r': + for events_file in self.g.readdir(events_dir): + if events_file['ftyp'] != 'r': continue - fullpath = "%s/%s" % (events_dir, f['name']) + fullpath = "%s/%s" % (events_dir, events_file['name']) event = "" action = "" for line in self.g.cat(fullpath).splitlines(): - m = event_exp.match(line) - if m: - event = m.group(1) + match = event_exp.match(line) + if match: + event = match.group(1) continue - m = action_exp.match(line) - if m: - action = m.group(1) + match = action_exp.match(line) + if match: + action = match.group(1) continue if event.strip() in ("button[ /]power", "button/power.*"): @@ -271,6 +239,9 @@ class Linux(Unix): self._persistent_grub1(persistent_root) def _persistent_grub1(self, new_root): + """Replaces non-persistent device name occurencies with persistent + ones in GRUB1 configuration files. + """ if self.g.is_file('/boot/grub/menu.lst'): grub1 = '/boot/grub/menu.lst' elif self.g.is_file('/etc/grub.conf'): @@ -283,7 +254,7 @@ class Linux(Unix): roots = self.g.aug_match('/files%s/title[*]/kernel/root' % grub1) for root in roots: dev = self.g.aug_get(root) - if not self.is_persistent(dev): + if not self._is_persistent(dev): # This is not always correct. Grub may contain root entries # for other systems, but we only support 1 OS per hard # disk, so this shouldn't harm. @@ -293,6 +264,9 @@ class Linux(Unix): self.g.aug_close() def _persistent_fstab(self): + """Replaces non-persistent device name occurencies in /etc/fstab with + persistent ones. + """ mpoints = self.g.mountpoints() if len(mpoints) == 0: pass # TODO: error handling @@ -317,6 +291,9 @@ class Linux(Unix): return root_dev def _convert_fstab_line(self, line, devices): + """Replace non-persistent device names in an fstab line to their UUID + equivalent + """ orig = line line = line.split('#')[0].strip() if len(line) == 0: @@ -330,9 +307,9 @@ class Linux(Unix): dev = entry[0] mpoint = entry[1] - if not self.is_persistent(dev): + if not self._is_persistent(dev): if mpoint in devices: - dev = "UUID=%s" % self.get_uuid(devices[mpoint]) + dev = "UUID=%s" % self._get_uuid(devices[mpoint]) entry[0] = dev else: # comment out the entry @@ -341,4 +318,46 @@ class Linux(Unix): return orig, dev, mpoint + def _do_collect_metadata(self): + """Collect metadata about the OS""" + super(Linux, self)._do_collect_metadata() + self.meta["USERS"] = " ".join(self._get_passworded_users()) + + # Delete the USERS metadata if empty + if not len(self.meta['USERS']): + self.out.warn("No passworded users found!") + del self.meta['USERS'] + + def _get_passworded_users(self): + """Returns a list of non-locked user accounts""" + users = [] + regexp = re.compile('(\S+):((?:!\S+)|(?:[^!*]\S+)|):(?:\S*:){6}') + + for line in self.g.cat('/etc/shadow').splitlines(): + match = regexp.match(line) + if not match: + continue + + user, passwd = match.groups() + if len(passwd) > 0 and passwd[0] == '!': + self.out.warn("Ignoring locked %s account." % user) + else: + users.append(user) + + return users + + def _is_persistent(self, dev): + """Checks if a device name is persistent.""" + return not self._persistent.match(dev) + + def _get_uuid(self, dev): + """Returns the UUID corresponding to a device""" + if dev in self._uuid: + return self._uuid[dev] + + uuid = self.g.vfs_uuid(dev) + assert len(uuid) + self._uuid[dev] = uuid + return uuid + # vim: set sta sts=4 shiftwidth=4 sw=4 et ai : diff --git a/image_creator/os_type/netbsd.py b/image_creator/os_type/netbsd.py index da5443f9993917fec03c18cbadb203fbfaa395f3..f8c7863259433e3b187ed87b4b5103e12a6042a1 100644 --- a/image_creator/os_type/netbsd.py +++ b/image_creator/os_type/netbsd.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +33,8 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module hosts OS-specific code for NetBSD.""" + from image_creator.os_type.unix import Unix diff --git a/image_creator/os_type/slackware.py b/image_creator/os_type/slackware.py index 5b0b571d0ecf6f060ecf8bda01e84d467e7fec51..e8f5a9ee7085d666f7e431accfc2c65fc7e46f15 100644 --- a/image_creator/os_type/slackware.py +++ b/image_creator/os_type/slackware.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +33,8 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module hosts OS-specific code for Slackware Linux""" + from image_creator.os_type.linux import Linux, sysprep @@ -46,7 +50,7 @@ class Slackware(Linux): # In slackware the metadata about installed packages are # stored in /var/log/packages. Clearing all /var/log files # will destroy the package management system. - self.foreach_file('/var/log', self.g.truncate, ftype='r', - exclude='/var/log/packages') + self._foreach_file('/var/log', self.g.truncate, ftype='r', + exclude='/var/log/packages') # vim: set sta sts=4 shiftwidth=4 sw=4 et ai : diff --git a/image_creator/os_type/ubuntu.py b/image_creator/os_type/ubuntu.py index c0ed91318949fafa62954846ad6d94aa2503f42a..b5706fcefc87ea6aa65df6ed101761103350216b 100644 --- a/image_creator/os_type/ubuntu.py +++ b/image_creator/os_type/ubuntu.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +33,8 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module hosts OS-specific code for Ubuntu Linux""" + from image_creator.os_type.linux import Linux @@ -39,6 +43,10 @@ class Ubuntu(Linux): def __init__(self, rootdev, ghandler, output): super(Ubuntu, self).__init__(rootdev, ghandler, output) + def _do_collect_metadata(self): + """Collect metadata about the OS""" + + super(Ubuntu, self)._do_collect_metadata() apps = self.g.inspect_list_applications(self.root) for app in apps: if app['app_name'] == 'kubuntu-desktop': diff --git a/image_creator/os_type/unix.py b/image_creator/os_type/unix.py index b0b3ae62e56ebe40d609048467e2cd5ca100ccfb..8b88b2fe60b92f1bdfaedd8080b44577cb5bca3a 100644 --- a/image_creator/os_type/unix.py +++ b/image_creator/os_type/unix.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +33,8 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module hosts OS-specific code common to all Unix-like OSs.""" + import re from image_creator.os_type import OSBase, sysprep @@ -47,8 +51,41 @@ class Unix(OSBase): '.kamaki.history' ] - def __init__(self, rootdev, ghandler, output): - super(Unix, self).__init__(rootdev, ghandler, output) + def _mountpoints(self): + """Return mountpoints in the correct order. + / should be mounted before /boot or /usr, /usr befor /usr/bin ... + """ + mps = self.g.inspect_get_mountpoints(self.root) + + def compare(a, b): + if len(a[0]) > len(b[0]): + return 1 + elif len(a[0]) == len(b[0]): + return 0 + else: + return -1 + mps.sort(compare) + + for mp in mps: + yield mp + + def _do_mount(self, readonly): + """Mount partitions in the correct order""" + + critical_mpoints = ('/', '/etc', '/root', '/home', '/var') + + mopts = 'ro' if readonly else 'rw' + for mp, dev in self._mountpoints(): + try: + self.g.mount_options(mopts, dev, mp) + except RuntimeError as msg: + if mp in critical_mpoints: + self.out.warn('unable to mount %s. Reason: %s' % (mp, msg)) + return False + else: + self.out.warn('%s (ignored)' % msg) + + return True @sysprep() def cleanup_cache(self, print_header=True): @@ -57,7 +94,7 @@ class Unix(OSBase): if print_header: self.out.output('Removing files under /var/cache') - self.foreach_file('/var/cache', self.g.rm, ftype='r') + self._foreach_file('/var/cache', self.g.rm, ftype='r') @sysprep() def cleanup_tmp(self, print_header=True): @@ -66,8 +103,8 @@ class Unix(OSBase): if print_header: self.out.output('Removing files under /tmp and /var/tmp') - self.foreach_file('/tmp', self.g.rm_rf, maxdepth=1) - self.foreach_file('/var/tmp', self.g.rm_rf, maxdepth=1) + self._foreach_file('/tmp', self.g.rm_rf, maxdepth=1) + self._foreach_file('/var/tmp', self.g.rm_rf, maxdepth=1) @sysprep() def cleanup_log(self, print_header=True): @@ -76,7 +113,7 @@ class Unix(OSBase): if print_header: self.out.output('Emptying all files under /var/log') - self.foreach_file('/var/log', self.g.truncate, ftype='r') + self._foreach_file('/var/log', self.g.truncate, ftype='r') @sysprep(enabled=False) def cleanup_mail(self, print_header=True): @@ -86,9 +123,9 @@ class Unix(OSBase): self.out.output('Removing files under /var/mail & /var/spool/mail') if self.g.is_dir('/var/spool/mail'): - self.foreach_file('/var/spool/mail', self.g.rm_rf, maxdepth=1) + self._foreach_file('/var/spool/mail', self.g.rm_rf, maxdepth=1) - self.foreach_file('/var/mail', self.g.rm_rf, maxdepth=1) + self._foreach_file('/var/mail', self.g.rm_rf, maxdepth=1) @sysprep() def cleanup_userdata(self, print_header=True): @@ -96,7 +133,7 @@ class Unix(OSBase): homedirs = ['/root'] if self.g.is_dir('/home/'): - homedirs += self.ls('/home/') + homedirs += self._ls('/home/') if print_header: self.out.output("Removing sensitive user data under %s" % @@ -108,6 +145,6 @@ class Unix(OSBase): if self.g.is_file(fname): self.g.scrub_file(fname) elif self.g.is_dir(fname): - self.foreach_file(fname, self.g.scrub_file, ftype='r') + self._foreach_file(fname, self.g.scrub_file, ftype='r') # vim: set sta sts=4 shiftwidth=4 sw=4 et ai : diff --git a/image_creator/os_type/windows.py b/image_creator/os_type/windows.py index f1e69210e3d11a905b08e955d2475ab6e4134858..bf237c2b3f5ecbd7052d9151e416b4aec534c3de 100644 --- a/image_creator/os_type/windows.py +++ b/image_creator/os_type/windows.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,11 +33,47 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module hosts OS-specific code common for the various Microsoft +Windows OSs.""" + from image_creator.os_type import OSBase +import hivex +import tempfile +import os + class Windows(OSBase): """OS class for Windows""" - pass + + def _do_collect_metadata(self): + """Collect metadata about the OS""" + super(Windows, self)._do_collect_metadata() + self.meta["USERS"] = " ".join(self._get_users()) + + def _get_users(self): + """Returns a list of users found in the images""" + samfd, sam = tempfile.mkstemp() + try: + systemroot = self.g.inspect_get_windows_systemroot(self.root) + path = "%s/system32/config/sam" % systemroot + path = self.g.case_sensitive_path(path) + self.g.download(path, sam) + + h = hivex.Hivex(sam) + + key = h.root() + + # Navigate to /SAM/Domains/Account/Users/Names + for child in ('SAM', 'Domains', 'Account', 'Users', 'Names'): + key = h.node_get_child(key, child) + + users = [h.node_name(x) for x in h.node_children(key)] + + finally: + os.unlink(sam) + + # Filter out the guest account + return filter(lambda x: x != "Guest", users) # vim: set sta sts=4 shiftwidth=4 sw=4 et ai : diff --git a/image_creator/output/__init__.py b/image_creator/output/__init__.py index e421c75aec020d1bf260562b0681cb7ff8d62d1e..b4aa7c080a09c5399d1ce0282f751560b576959e 100644 --- a/image_creator/output/__init__.py +++ b/image_creator/output/__init__.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +33,12 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This package is intended to provide output classes for printing messages and +progress bars. The user can change the output behaviour of the program by +subclassing the Output class and assigning the derived one as the output class +of the various parts of the image-creator package. +""" + class Output(object): """A class for printing program output""" diff --git a/image_creator/output/cli.py b/image_creator/output/cli.py index f88fdbf1d4ca5a500e9db195dc9880500cfa572f..46c84c9d14bde79436ff2847da09e21ba356d819 100644 --- a/image_creator/output/cli.py +++ b/image_creator/output/cli.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -61,7 +63,7 @@ def success(msg, new_line, colored, stream): def clear(stream): - #clear the page + """Clears the terminal screen.""" if stream.isatty(): stream.write('\033[H\033[2J') diff --git a/image_creator/output/composite.py b/image_creator/output/composite.py index a9502ecc6a62ee4c259592b1ddc2f30cc8d5a9b6..52285047d2ed8486ffbfeb660b76dbf8b8306c9a 100644 --- a/image_creator/output/composite.py +++ b/image_creator/output/composite.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +33,8 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module implements the CompositeOutput output class""" + from image_creator.output import Output diff --git a/image_creator/output/dialog.py b/image_creator/output/dialog.py index 2790b1c7c9be2e10a5839fff63a7400ebd17635a..f2b10e84a7697ffa04fa21220ce57ca928a32b05 100644 --- a/image_creator/output/dialog.py +++ b/image_creator/output/dialog.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +33,8 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module provides various dialog-based Output classes""" + from image_creator.output import Output import time import fcntl diff --git a/image_creator/rsync.py b/image_creator/rsync.py index 08bc27a6d353a2f0f30bd125d27119ae85e4af69..833d0be745cb50af218eaefa06579a25d9c3b710 100644 --- a/image_creator/rsync.py +++ b/image_creator/rsync.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +33,8 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module provides an interface for the rsync utility""" + import subprocess import time import signal @@ -98,7 +102,7 @@ class Rsync: stdout=subprocess.PIPE, bufsize=0) try: total = 0 - for line in iter(dry_run.stdout.readline, b''): + for _ in iter(dry_run.stdout.readline, b''): total += 1 finally: dry_run.communicate() @@ -113,7 +117,7 @@ class Rsync: try: t = time.time() i = 0 - for line in iter(run.stdout.readline, b''): + for _ in iter(run.stdout.readline, b''): i += 1 current = time.time() if current - t > 0.1: diff --git a/image_creator/util.py b/image_creator/util.py index 61a97e933a64c045b335148633d997c01c3a35a4..fb0d0fa0b319c3c52c25a262de23eee1695f499e 100644 --- a/image_creator/util.py +++ b/image_creator/util.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or @@ -31,6 +33,10 @@ # interpreted as representing official policies, either expressed # or implied, of GRNET S.A. +"""This module provides various helper functions to be used by other parts of +the package. +""" + import sh import hashlib import time diff --git a/image_creator/version.py b/image_creator/version.py new file mode 100644 index 0000000000000000000000000000000000000000..161d848580ed6e97a6e36491617a7d32ee17dc83 --- /dev/null +++ b/image_creator/version.py @@ -0,0 +1,7 @@ +__version__ = "0.4.1" +__version_info__ = ['0', '4', '1'] +__version_vcs_info__ = { + 'branch': 'master', + 'revid': '628054a', + 'revno': 313} +__version_user_info__ = "skalkoto@darkstar.admin.grnet.gr" diff --git a/setup.py b/setup.py index a524373cb65f92379566e7b84c8067eddc329a07..3c673663109a1bf9c128a7fa57a7c522865ff11c 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,6 @@ #!/usr/bin/env python - +# -*- coding: utf-8 -*- +# # Copyright 2012 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or diff --git a/version b/version new file mode 100644 index 0000000000000000000000000000000000000000..267577d47e497a0630bc454b3f74c4fd9a10ced4 --- /dev/null +++ b/version @@ -0,0 +1 @@ +0.4.1