Merge branch 'develop'
[snf-image-creator] / image_creator / dialog_menu.py
index b6d3162..4f24b1e 100644 (file)
@@ -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
 # 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"],
@@ -63,6 +68,8 @@ CONFIGURATION_TASKS = [
     ("File injection", ["EnforcePersonality"], ["windows", "linux"])
 ]
 
+SYSPREP_PARAM_MAXLEN = 20
+
 
 class MetadataMonitor(object):
     """Monitors image metadata chages"""
@@ -108,15 +115,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,15 +151,15 @@ 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
 
         session['upload'] = filename
         break
 
-    gauge = GaugeOutput(d, "Image Upload", "Uploading...")
+    gauge = GaugeOutput(d, "Image Upload", "Uploading ...")
     try:
         out = image.out
         out.add(gauge)
@@ -170,15 +177,16 @@ def upload_image(session):
                                       "Calculating block hashes",
                                       "Uploading missing blocks")
                 # Upload md5sum file
-                out.output("Uploading md5sum file...")
+                out.output("Uploading md5sum file ...")
                 md5str = "%s %s\n" % (session['checksum'], filename)
                 kamaki.upload(StringIO.StringIO(md5str), size=len(md5str),
                               remote_path="%s.md5sum" % filename)
                 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,35 +195,48 @@ 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
 
+    name = ""
+    description = session['metadata']['DESCRIPTION'] if 'DESCRIPTION' in \
+        session['metadata'] else ""
+
     while 1:
-        (code, answer) = d.inputbox("Please provide a registration name:",
-                                    width=WIDTH)
+        fields = [
+            ("Registration name:", name, 60),
+            ("Description (optional):", description, 80)]
+
+        (code, output) = d.form(
+            "Please provide the following registration info:", height=11,
+            width=WIDTH, form_height=2, fields=fields)
+
         if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
             return False
 
-        name = answer.strip()
+        name, description = output
+        name = name.strip()
+        description = description.strip()
+
         if len(name) == 0:
             d.msgbox("Registration name cannot be empty", width=SMALL_WIDTH)
             continue
@@ -230,6 +251,7 @@ def register_image(session):
 
         break
 
+    session['metadata']['DESCRIPTION'] = description
     metadata = {}
     metadata.update(session['metadata'])
     if 'task_metadata' in session:
@@ -237,63 +259,142 @@ def register_image(session):
             metadata[key] = 'yes'
 
     img_type = "public" if is_public else "private"
-    gauge = GaugeOutput(d, "Image Registration", "Registering image...")
+    gauge = GaugeOutput(d, "Image Registration", "Registering image ...")
     try:
         out = session['image'].out
         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)
+                out.output("Uploading metadata file ...")
+                metastring = unicode(json.dumps(result, ensure_ascii=False))
                 kamaki.upload(StringIO.StringIO(metastring),
                               size=len(metastring),
                               remote_path="%s.meta" % session['upload'])
                 out.success("done")
                 if is_public:
-                    out.output("Sharing metadata and md5sum files...")
+                    out.output("Sharing metadata and md5sum files ...")
                     kamaki.share("%s.meta" % session['upload'])
                     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)
+    to_delete = map(lambda x: x.strip('"'), to_delete)  # Needed for OpenSUSE
+
+    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 +405,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_item = "Add/Edit"
+            else:
+                default_item = "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"
@@ -371,6 +502,15 @@ def add_property(session):
     return True
 
 
+def show_properties_help(session):
+    """Show help for image properties"""
+    d = session['dialog']
+
+    help_file = get_help_file("image_properties")
+    assert os.path.exists(help_file)
+    d.textbox(help_file, title="Image Properties", width=70, height=40)
+
+
 def modify_properties(session):
     """Modify an existing image property"""
     d = session['dialog']
@@ -380,6 +520,19 @@ def modify_properties(session):
         for (key, val) in session['metadata'].items():
             choices.append((str(key), str(val)))
 
+        if len(choices) == 0:
+            code = d.yesno(
+                "No image properties are available. "
+                "Would you like to add a new one?", width=WIDTH, help_button=1)
+            if code == d.DIALOG_OK:
+                if not add_property(session):
+                    return True
+            elif code == d.DIALOG_CANCEL:
+                return True
+            elif code == d.DIALOG_HELP:
+                show_properties_help(session)
+            continue
+
         (code, choice) = d.menu(
             "In this menu you can edit existing image properties or add new "
             "ones. Be careful! Most properties have special meaning and "
@@ -409,9 +562,7 @@ def modify_properties(session):
         elif code == d.DIALOG_EXTRA:
             add_property(session)
         elif code == 'help':
-            help_file = get_help_file("image_properties")
-            assert os.path.exists(help_file)
-            d.textbox(help_file, title="Image Properties", width=70, height=40)
+            show_properties_help(session)
 
 
 def delete_properties(session):
@@ -422,8 +573,14 @@ def delete_properties(session):
     for (key, val) in session['metadata'].items():
         choices.append((key, "%s" % val, 0))
 
+    if len(choices) == 0:
+        d.msgbox("No available images properties to delete!",
+                 width=SMALL_WIDTH)
+        return True
+
     (code, to_delete) = d.checklist("Choose which properties to delete:",
                                     choices=choices, width=WIDTH)
+    to_delete = map(lambda x: x.strip('"'), to_delete)  # needed for OpenSUSE
 
     # If the user exits with ESC or CANCEL, the returned tag list is empty.
     for i in to_delete:
@@ -440,6 +597,12 @@ def delete_properties(session):
 def exclude_tasks(session):
     """Exclude specific tasks from running during image deployment"""
     d = session['dialog']
+    image = session['image']
+
+    if image.is_unsupported():
+        d.msgbox("Image deployment configuration is disabled for unsupported "
+                 "images.", width=SMALL_WIDTH)
+        return False
 
     index = 0
     displayed_index = 1
@@ -473,6 +636,7 @@ def exclude_tasks(session):
             choices=choices, height=19, list_height=8, width=WIDTH,
             help_button=1, extra_button=1, extra_label="No Config",
             title="Exclude Configuration Tasks")
+        tags = map(lambda x: x.strip('"'), tags)  # Needed for OpenSUSE
 
         if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
             return False
@@ -502,6 +666,75 @@ def exclude_tasks(session):
     return True
 
 
+def sysprep_params(session):
+    """Collect the needed sysprep parameters"""
+    d = session['dialog']
+    image = session['image']
+
+    available = image.os.sysprep_params
+    needed = image.os.needed_sysprep_params
+
+    if len(needed) == 0:
+        return True
+
+    def print_form(names, extra_button=False):
+        """print the dialog form providing sysprep_params"""
+        fields = []
+        for name in names:
+            param = needed[name]
+            default = str(available[name]) if name in available else ""
+            fields.append(("%s: " % param.description, default,
+                           SYSPREP_PARAM_MAXLEN))
+
+        kwargs = {}
+        if extra_button:
+            kwargs['extra_button'] = 1
+            kwargs['extra_label'] = "Advanced"
+
+        txt = "Please provide the following system preparation parameters:"
+        return d.form(txt, height=13, width=WIDTH, form_height=len(fields),
+                      fields=fields, **kwargs)
+
+    def check_params(names, values):
+        """check if the provided sysprep parameters have leagal values"""
+        for i in range(len(names)):
+            param = needed[names[i]]
+            try:
+                normalized = param.type(values[i])
+                if param.validate(normalized):
+                    image.os.sysprep_params[names[i]] = normalized
+                    continue
+            except ValueError:
+                pass
+
+            d.msgbox("Invalid value for parameter: `%s'" % names[i],
+                     width=SMALL_WIDTH)
+            return False
+        return True
+
+    simple_names = [k for k, v in needed.items() if v.default is None]
+    advanced_names = [k for k, v in needed.items() if v.default is not None]
+
+    while 1:
+        code, output = print_form(simple_names, extra_button=True)
+
+        if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
+            return False
+        if code == d.DIALOG_EXTRA:
+            while 1:
+                code, output = print_form(advanced_names)
+                if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
+                    break
+                if check_params(advanced_names, output):
+                    break
+            continue
+
+        if check_params(simple_names, output):
+            break
+
+    return True
+
+
 def sysprep(session):
     """Perform various system preperation tasks on the image"""
     d = session['dialog']
@@ -518,9 +751,6 @@ def sysprep(session):
 
     wrapper = textwrap.TextWrapper(width=WIDTH - 5)
 
-    help_title = "System Preperation Tasks"
-    sysprep_help = "%s\n%s\n\n" % (help_title, '=' * len(help_title))
-
     syspreps = image.os.list_syspreps()
 
     if len(syspreps) == 0:
@@ -531,6 +761,10 @@ def sysprep(session):
     while 1:
         choices = []
         index = 0
+
+        help_title = "System Preperation Tasks"
+        sysprep_help = "%s\n%s\n\n" % (help_title, '=' * len(help_title))
+
         for sysprep in syspreps:
             name, descr = image.os.sysprep_info(sysprep)
             display_name = name.replace('-', ' ').capitalize()
@@ -546,6 +780,7 @@ def sysprep(session):
             "run on the image. Press <Help> to see details about the system "
             "preparation tasks.", title="Run system preparation tasks",
             choices=choices, width=70, ok_label="Run", help_button=1)
+        tags = map(lambda x: x.strip('"'), tags)  # Needed for OpenSUSE
 
         if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
             return False
@@ -564,34 +799,26 @@ def sysprep(session):
                          title="System Preperation", width=SMALL_WIDTH)
                 continue
 
+            if not sysprep_params(session):
+                continue
+
             infobox = InfoBoxOutput(d, "Image Configuration")
             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 +902,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 +917,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):
@@ -698,7 +925,7 @@ def main_menu(session):
                 break
         elif choice == "Reset":
             if confirm_reset(d):
-                d.infobox("Resetting snf-image-creator. Please wait...",
+                d.infobox("Resetting snf-image-creator. Please wait ...",
                           width=SMALL_WIDTH)
                 raise Reset
         elif choice == "Help":