Add --cloud option in snf-image-creator
[snf-image-creator] / image_creator / dialog_wizard.py
1 # -*- coding: utf-8 -*-
2 #
3 # Copyright 2012 GRNET S.A. All rights reserved.
4 #
5 # Redistribution and use in source and binary forms, with or
6 # without modification, are permitted provided that the following
7 # conditions are met:
8 #
9 #   1. Redistributions of source code must retain the above
10 #      copyright notice, this list of conditions and the following
11 #      disclaimer.
12 #
13 #   2. Redistributions in binary form must reproduce the above
14 #      copyright notice, this list of conditions and the following
15 #      disclaimer in the documentation and/or other materials
16 #      provided with the distribution.
17 #
18 # THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
19 # OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
21 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
22 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
25 # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
26 # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
27 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
28 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29 # POSSIBILITY OF SUCH DAMAGE.
30 #
31 # The views and conclusions contained in the software and
32 # documentation are those of the authors and should not be
33 # interpreted as representing official policies, either expressed
34 # or implied, of GRNET S.A.
35
36 """This module implements the "wizard" mode of the dialog-based version of
37 snf-image-creator.
38 """
39
40 import time
41 import StringIO
42
43 from image_creator.kamaki_wrapper import Kamaki, ClientError
44 from image_creator.util import MD5, FatalError
45 from image_creator.output.cli import OutputWthProgress
46 from image_creator.dialog_util import extract_image, update_background_title, \
47     add_cloud, edit_cloud
48
49 PAGE_WIDTH = 70
50
51
52 class WizardExit(Exception):
53     """Exception used to exit the wizard"""
54     pass
55
56
57 class WizardInvalidData(Exception):
58     """Exception triggered when the user provided data are invalid"""
59     pass
60
61
62 class Wizard:
63     """Represents a dialog-based wizard
64
65     The wizard is a collection of pages that have a "Next" and a "Back" button
66     on them. The pages are used to collect user data.
67     """
68
69     def __init__(self, session):
70         self.session = session
71         self.pages = []
72         self.session['wizard'] = {}
73         self.d = session['dialog']
74
75     def add_page(self, page):
76         """Add a new page to the wizard"""
77         self.pages.append(page)
78
79     def run(self):
80         """Run the wizard"""
81         idx = 0
82         while True:
83             try:
84                 idx += self.pages[idx].run(self.session, idx, len(self.pages))
85             except WizardExit:
86                 return False
87             except WizardInvalidData:
88                 continue
89
90             if idx >= len(self.pages):
91                 msg = "All necessary information has been gathered:\n\n"
92                 for page in self.pages:
93                     msg += " * %s\n" % page.info
94                 msg += "\nContinue with the image creation process?"
95
96                 ret = self.d.yesno(
97                     msg, width=PAGE_WIDTH, height=8 + len(self.pages),
98                     ok_label="Yes", cancel="Back", extra_button=1,
99                     extra_label="Quit", title="Confirmation")
100
101                 if ret == self.d.DIALOG_CANCEL:
102                     idx -= 1
103                 elif ret == self.d.DIALOG_EXTRA:
104                     return False
105                 elif ret == self.d.DIALOG_OK:
106                     return True
107
108             if idx < 0:
109                 return False
110
111
112 class WizardPage(object):
113     """Represents a page in a wizard"""
114     NEXT = 1
115     PREV = -1
116
117     def __init__(self, **kargs):
118         validate = kargs['validate'] if 'validate' in kargs else lambda x: x
119         setattr(self, "validate", validate)
120
121         display = kargs['display'] if 'display' in kargs else lambda x: x
122         setattr(self, "display", display)
123
124     def run(self, session, index, total):
125         """Display this wizard page
126
127         This function is used by the wizard program when accessing a page.
128         """
129         raise NotImplementedError
130
131
132 class WizardRadioListPage(WizardPage):
133     """Represent a Radio List in a wizard"""
134     def __init__(self, name, printable, message, choices, **kargs):
135         super(WizardRadioListPage, self).__init__(**kargs)
136         self.name = name
137         self.printable = printable
138         self.message = message
139         self.choices = choices
140         self.title = kargs['title'] if 'title' in kargs else ''
141         self.default = kargs['default'] if 'default' in kargs else ""
142
143     def run(self, session, index, total):
144         d = session['dialog']
145         w = session['wizard']
146
147         choices = []
148         for i in range(len(self.choices)):
149             default = 1 if self.choices[i][0] == self.default else 0
150             choices.append((self.choices[i][0], self.choices[i][1], default))
151
152         (code, answer) = d.radiolist(
153             self.message, height=10, width=PAGE_WIDTH, ok_label="Next",
154             cancel="Back", choices=choices,
155             title="(%d/%d) %s" % (index + 1, total, self.title))
156
157         if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
158             return self.PREV
159
160         w[self.name] = self.validate(answer)
161         self.default = answer
162         self.info = "%s: %s" % (self.printable, self.display(w[self.name]))
163
164         return self.NEXT
165
166
167 class WizardInputPage(WizardPage):
168     """Represents an input field in a wizard"""
169     def __init__(self, name, printable, message, **kargs):
170         super(WizardInputPage, self).__init__(**kargs)
171         self.name = name
172         self.printable = printable
173         self.message = message
174         self.info = "%s: <none>" % self.printable
175         self.title = kargs['title'] if 'title' in kargs else ''
176         self.init = kargs['init'] if 'init' in kargs else ''
177
178     def run(self, session, index, total):
179         d = session['dialog']
180         w = session['wizard']
181
182         (code, answer) = d.inputbox(
183             self.message, init=self.init, width=PAGE_WIDTH, ok_label="Next",
184             cancel="Back", title="(%d/%d) %s" % (index + 1, total, self.title))
185
186         if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
187             return self.PREV
188
189         value = answer.strip()
190         self.init = value
191         w[self.name] = self.validate(value)
192         self.info = "%s: %s" % (self.printable, self.display(w[self.name]))
193
194         return self.NEXT
195
196
197 def start_wizard(session):
198     """Run the image creation wizard"""
199
200     d = session['dialog']
201     clouds = Kamaki.get_clouds()
202     if not len(clouds):
203         if not add_cloud(session):
204             return False
205     else:
206         while 1:
207             choices = []
208             for (name, cloud) in clouds.items():
209                 descr = cloud['description'] if 'description' in cloud else ''
210                 choices.append((name, descr))
211
212             (code, choice) = d.menu(
213                 "In this menu you can select existing cloud account to use "
214                 " or add new ones. Press <Select> to select an existing "
215                 "account or <Add> to add a new one.", height=18,
216                 width=PAGE_WIDTH, choices=choices, menu_height=10,
217                 ok_label="Select", extra_button=1, extra_label="Add",
218                 title="Clouds")
219
220             if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
221                 return False
222             elif code == d.DIALOG_OK:  # Select button
223                 account = Kamaki.get_account(choice)
224                 if not account:
225                     if not d.yesno("Then cloud you have selected is not "
226                                    "valid! Would you like to edit it?",
227                                    width=PAGE_WIDTH, defaultno=0):
228                         edit_cloud(session, choice)
229                     continue
230                 break
231             elif code == d.DIALOG_EXTRA:  # Add button
232                 add_cloud(session)
233
234     distro = session['image'].distro
235     ostype = session['image'].ostype
236     name = WizardInputPage(
237         "ImageName", "Image Name", "Please provide a name for the image:",
238         title="Image Name", init=ostype if distro == "unknown" else distro)
239
240     descr = WizardInputPage(
241         "ImageDescription", "Image Description",
242         "Please provide a description for the image:",
243         title="Image Description", init=session['metadata']['DESCRIPTION'] if
244         'DESCRIPTION' in session['metadata'] else '')
245
246     registration = WizardRadioListPage(
247         "ImageRegistration", "Registration Type",
248         "Please provide a registration type:",
249         [("Private", "Image is accessible only by this user"),
250          ("Public", "Everyone can create VMs from this image")],
251         title="Registration Type", default="Private")
252
253     w = Wizard(session)
254
255     w.add_page(name)
256     w.add_page(descr)
257     w.add_page(registration)
258
259     if w.run():
260         create_image(session, account)
261     else:
262         return False
263
264     return True
265
266
267 def create_image(session, account):
268     """Create an image using the information collected by the wizard"""
269     d = session['dialog']
270     image = session['image']
271     wizard = session['wizard']
272
273     with_progress = OutputWthProgress(True)
274     out = image.out
275     out.add(with_progress)
276     try:
277         out.clear()
278
279         #Sysprep
280         image.os.do_sysprep()
281         metadata = image.os.meta
282
283         #Shrink
284         size = image.shrink()
285         session['shrinked'] = True
286         update_background_title(session)
287
288         metadata.update(image.meta)
289         metadata['DESCRIPTION'] = wizard['ImageDescription']
290
291         #MD5
292         md5 = MD5(out)
293         session['checksum'] = md5.compute(image.device, size)
294
295         #Metadata
296         metastring = '\n'.join(
297             ['%s=%s' % (key, value) for (key, value) in metadata.items()])
298         metastring += '\n'
299
300         out.output()
301         try:
302             out.output("Uploading image to pithos:")
303             kamaki = Kamaki(account, out)
304
305             name = "%s-%s.diskdump" % (wizard['ImageName'],
306                                        time.strftime("%Y%m%d%H%M"))
307             pithos_file = ""
308             with open(image.device, 'rb') as f:
309                 pithos_file = kamaki.upload(f, size, name,
310                                             "(1/4)  Calculating block hashes",
311                                             "(2/4)  Uploading missing blocks")
312
313             out.output("(3/4)  Uploading metadata file ...", False)
314             kamaki.upload(StringIO.StringIO(metastring), size=len(metastring),
315                           remote_path="%s.%s" % (name, 'meta'))
316             out.success('done')
317             out.output("(4/4)  Uploading md5sum file ...", False)
318             md5sumstr = '%s %s\n' % (session['checksum'], name)
319             kamaki.upload(StringIO.StringIO(md5sumstr), size=len(md5sumstr),
320                           remote_path="%s.%s" % (name, 'md5sum'))
321             out.success('done')
322             out.output()
323
324             is_public = True if wizard['ImageRegistration'] == "Public" else \
325                 False
326             out.output('Registering %s image with cyclades ...' %
327                        wizard['ImageRegistration'].lower(), False)
328             kamaki.register(wizard['ImageName'], pithos_file, metadata,
329                             is_public)
330             out.success('done')
331             if is_public:
332                 out.output("Sharing md5sum file ...", False)
333                 kamaki.share("%s.md5sum" % name)
334                 out.success('done')
335                 out.output("Sharing metadata file ...", False)
336                 kamaki.share("%s.meta" % name)
337                 out.success('done')
338
339             out.output()
340
341         except ClientError as e:
342             raise FatalError("Pithos client: %d %s" % (e.status, e.message))
343     finally:
344         out.remove(with_progress)
345
346     msg = "The %s image was successfully uploaded to Pithos and registered " \
347           "with Cyclades. Would you like to keep a local copy of the image?" \
348           % wizard['ImageRegistration'].lower()
349     if not d.yesno(msg, width=PAGE_WIDTH):
350         extract_image(session)
351
352 # vim: set sta sts=4 shiftwidth=4 sw=4 et ai :