Make os_type list_sysprep method return 1 list
[snf-image-creator] / image_creator / dialog_main.py
1 #!/usr/bin/env python
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 import dialog
37 import sys
38 import os
39 import textwrap
40 import signal
41 import StringIO
42
43 from image_creator import __version__ as version
44 from image_creator.util import FatalError, MD5
45 from image_creator.output.dialog import InitializationOutput, GaugeOutput
46 from image_creator.disk import Disk
47 from image_creator.os_type import os_cls
48 from image_creator.kamaki_wrapper import Kamaki, ClientError
49
50 MSGBOX_WIDTH = 60
51 YESNO_WIDTH = 50
52 MENU_WIDTH = 70
53 INPUTBOX_WIDTH = 70
54
55 CONFIGURATION_TASKS = {
56     "FixPartitionTable":
57         "Enlarge last partition to use all the available space",
58     "FilesystemResizeUnmounted":
59         "Resize file system to use all the available space",
60     "AddSwap": "Set up the swap partition and add an entry in fstab",
61     "DeleteSSHKeys": "Remove ssh keys and in some cases recreate them",
62     "DisableRemoteDesktopConnections":
63         "Temporary Disable Remote Desktop Connections",
64     "40SELinuxAutorelabel": "Force the system to relabel at next boot",
65     "AssignHostname": "Assign Hostname/Computer Name to the instance",
66     "ChangePassword": "Changes Password for specified users",
67     "EnforcePersonality": "Inject files to the instance",
68     "FilesystemResizeMounted":
69         "Resize filesystem to use all the available space"}
70
71
72 class Reset(Exception):
73     pass
74
75
76 def confirm_exit(d, msg=''):
77     return not d.yesno("%s Do you want to exit?" % msg, width=YESNO_WIDTH)
78
79
80 def confirm_reset(d):
81     return not d.yesno(
82         "Are you sure you want to reset everything?",
83         width=YESNO_WIDTH)
84
85
86 def extract_image(session):
87     d = session['dialog']
88     dir = os.getcwd()
89     while 1:
90         if dir and dir[-1] != os.sep:
91             dir = dir + os.sep
92
93         (code, path) = d.fselect(dir, 10, 50, title="Save image as...")
94         if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
95             return False
96
97         if os.path.isdir(path):
98             dir = path
99             continue
100
101         if os.path.isdir("%s.meta" % path):
102             d.msgbox("Can't overwrite directory `%s.meta'" % path,
103                      width=MSGBOX_WIDTH)
104             continue
105
106         if os.path.isdir("%s.md5sum" % path):
107             d.msgbox("Can't overwrite directory `%s.md5sum'" % path,
108                      width=MSGBOX_WIDTH)
109             continue
110
111         basedir = os.path.dirname(path)
112         name = os.path.basename(path)
113         if not os.path.exists(basedir):
114             d.msgbox("Directory `%s' does not exist" % basedir,
115                      width=MSGBOX_WIDTH)
116             continue
117
118         dir = basedir
119         if len(name) == 0:
120             continue
121
122         files = ["%s%s" % (path, ext) for ext in ('', '.meta', '.md5sum')]
123         overwrite = filter(os.path.exists, files)
124
125         if len(overwrite) > 0:
126             if d.yesno("The following file(s) exist:\n"
127                         "%s\nDo you want to overwrite them?" %
128                         "\n".join(overwrite), width=YESNO_WIDTH):
129                 continue
130
131         out = GaugeOutput(d, "Image Extraction", "Extracting image...")
132         try:
133             dev = session['device']
134             if "checksum" not in session:
135                 size = dev.meta['SIZE']
136                 md5 = MD5(out)
137                 session['checksum'] = md5.compute(session['snapshot'], size)
138
139             # Extract image file
140             dev.out = out
141             dev.dump(path)
142
143             # Extract metadata file
144             out.output("Extracting metadata file...")
145             metastring = '\n'.join(
146                 ['%s=%s' % (k, v) for (k, v) in session['metadata'].items()])
147             metastring += '\n'
148             with open('%s.meta' % path, 'w') as f:
149                 f.write(metastring)
150             out.success('done')
151
152             # Extract md5sum file
153             out.output("Extracting md5sum file...")
154             md5str = "%s %s\n" % (session['checksum'], name)
155             with open('%s.md5sum' % path, 'w') as f:
156                 f.write(md5str)
157             out.success("done")
158
159         finally:
160             out.cleanup()
161         d.msgbox("Image file `%s' was successfully extracted!" % path,
162                  width=MSGBOX_WIDTH)
163         break
164
165     return True
166
167
168 def upload_image(session):
169     d = session["dialog"]
170     size = session['device'].meta['SIZE']
171
172     if "account" not in session:
173         d.msgbox("You need to provide your ~okeanos login username before you "
174                  "can upload images to pithos+", width=MSGBOX_WIDTH)
175         return False
176
177     if "token" not in session:
178         d.msgbox("You need to provide your ~okeanos account authentication "
179                  "token before you can upload images to pithos+",
180                  width=MSGBOX_WIDTH)
181         return False
182
183     while 1:
184         init = session["upload"] if "upload" in session else ''
185         (code, answer) = d.inputbox("Please provide a filename:", init=init,
186                                     width=INPUTBOX_WIDTH)
187
188         if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
189             return False
190
191         filename = answer.strip()
192         if len(filename) == 0:
193             d.msgbox("Filename cannot be empty", width=MSGBOX_WIDTH)
194             continue
195
196         break
197
198     out = GaugeOutput(d, "Image Upload", "Uploading...")
199     if 'checksum' not in session:
200         md5 = MD5(out)
201         session['checksum'] = md5.compute(session['snapshot'], size)
202     try:
203         kamaki = Kamaki(session['account'], session['token'], out)
204         try:
205             # Upload image file
206             with open(session['snapshot'], 'rb') as f:
207                 session["upload"] = kamaki.upload(f, size, filename,
208                                                   "Calculating block hashes",
209                                                   "Uploading missing blocks")
210             # Upload metadata file
211             out.output("Uploading metadata file...")
212             metastring = '\n'.join(
213                 ['%s=%s' % (k, v) for (k, v) in session['metadata'].items()])
214             metastring += '\n'
215             kamaki.upload(StringIO.StringIO(metastring), size=len(metastring),
216                           remote_path="%s.meta" % filename)
217             out.success("done")
218
219             # Upload md5sum file
220             out.output("Uploading md5sum file...")
221             md5str = "%s %s\n" % (session['checksum'], filename)
222             kamaki.upload(StringIO.StringIO(md5str), size=len(md5str),
223                           remote_path="%s.md5sum" % filename)
224             out.success("done")
225
226         except ClientError as e:
227             d.msgbox("Error in pithos+ client: %s" % e.message,
228                      title="Pithos+ Client Error", width=MSGBOX_WIDTH)
229             if 'upload' in session:
230                 del session['upload']
231             return False
232     finally:
233         out.cleanup()
234
235     d.msgbox("Image file `%s' was successfully uploaded to pithos+" % filename,
236              width=MSGBOX_WIDTH)
237     return True
238
239
240 def register_image(session):
241     d = session["dialog"]
242
243     if "account" not in session:
244         d.msgbox("You need to provide your ~okeanos login username before you "
245                  "can register an images to cyclades",
246                  width=MSGBOX_WIDTH)
247         return False
248
249     if "token" not in session:
250         d.msgbox("You need to provide your ~okeanos account authentication "
251                  "token before you can register an images to cyclades",
252                  width=MSGBOX_WIDTH)
253         return False
254
255     if "upload" not in session:
256         d.msgbox("You need to have an image uploaded to pithos+ before you "
257                  "can register it to cyclades",
258                  width=MSGBOX_WIDTH)
259         return False
260
261     while 1:
262         (code, answer) = d.inputbox("Please provide a registration name:"
263                                 " be registered:", width=INPUTBOX_WIDTH)
264         if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
265             return False
266
267         name = answer.strip()
268         if len(name) == 0:
269             d.msgbox("Registration name cannot be empty", width=MSGBOX_WIDTH)
270             continue
271         break
272
273     out = GaugeOutput(d, "Image Registration", "Registrating image...")
274     try:
275         out.output("Registring image to cyclades...")
276         try:
277             kamaki = Kamaki(session['account'], session['token'], out)
278             kamaki.register(name, session['upload'], session['metadata'])
279             out.success('done')
280         except ClientError as e:
281             d.msgbox("Error in pithos+ client: %s" % e.message)
282             return False
283     finally:
284         out.cleanup()
285
286     d.msgbox("Image `%s' was successfully registered to cyclades as `%s'" %
287              (session['upload'], name), width=MSGBOX_WIDTH)
288     return True
289
290
291 def kamaki_menu(session):
292     d = session['dialog']
293     default_item = "Account"
294     while 1:
295         account = session["account"] if "account" in session else "<none>"
296         token = session["token"] if "token" in session else "<none>"
297         upload = session["upload"] if "upload" in session else "<none>"
298         (code, choice) = d.menu(
299             "Choose one of the following or press <Back> to go back.",
300             width=MENU_WIDTH,
301             choices=[("Account", "Change your ~okeanos username: %s" %
302                       account),
303                      ("Token", "Change your ~okeanos token: %s" % token),
304                      ("Upload", "Upload image to pithos+"),
305                      ("Register", "Register image to cyclades: %s" % upload)],
306             cancel="Back",
307             default_item=default_item,
308             help_button=1,
309             title="Image Registration Menu")
310
311         if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
312             return False
313
314         if choice == "Account":
315             default_item = "Account"
316             (code, answer) = d.inputbox(
317                 "Please provide your ~okeanos account e-mail address:",
318                 init=session["account"] if "account" in session else '',
319                 width=70)
320             if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
321                 continue
322             if len(answer) == 0 and "account" in session:
323                     del session["account"]
324             else:
325                 session["account"] = answer.strip()
326                 default_item = "Token"
327         elif choice == "Token":
328             default_item = "Token"
329             (code, answer) = d.inputbox(
330                 "Please provide your ~okeanos account authetication token:",
331                 init=session["token"] if "token" in session else '',
332                 width=70)
333             if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
334                 continue
335             if len(answer) == 0 and "token" in session:
336                 del session["token"]
337             else:
338                 session["token"] = answer.strip()
339                 default_item = "Upload"
340         elif choice == "Upload":
341             if upload_image(session):
342                 default_item = "Register"
343             else:
344                 default_item = "Upload"
345         elif choice == "Register":
346             if register_image(session):
347                 return True
348             else:
349                 default_item = "Register"
350
351
352 def add_property(session):
353     d = session['dialog']
354
355     while 1:
356         (code, answer) = d.inputbox("Please provide a name for a new image"
357                                     " property:", width=INPUTBOX_WIDTH)
358         if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
359             return False
360
361         name = answer.strip()
362         if len(name) == 0:
363             d.msgbox("A property name cannot be empty", width=MSGBOX_WIDTH)
364             continue
365
366         break
367
368     while 1:
369         (code, answer) = d.inputbox("Please provide a value for image "
370                                    "property %s" % name, width=INPUTBOX_WIDTH)
371         if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
372             return False
373
374         value = answer.strip()
375         if len(value) == 0:
376             d.msgbox("Value cannot be empty", width=MSGBOX_WIDTH)
377             continue
378
379         break
380
381     session['metadata'][name] = value
382
383     return True
384
385
386 def modify_properties(session):
387     d = session['dialog']
388
389     while 1:
390         choices = []
391         for (key, val) in session['metadata'].items():
392             choices.append((str(key), str(val)))
393
394         (code, choice) = d.menu(
395             "In this menu you can edit existing image properties or add new "
396             "ones. Be carefull! Most properties have special meaning and "
397             "alter the image deployment behaviour. Press <HELP> to see more "
398             "information about image properties. Press <BACK> when done.",
399             height=18,
400             width=MENU_WIDTH,
401             choices=choices, menu_height=10,
402             ok_label="EDIT", extra_button=1, extra_label="ADD", cancel="BACK",
403             help_button=1, help_label="HELP", title="Image Metadata")
404
405         if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
406             break
407         # Edit button
408         elif code == d.DIALOG_OK:
409             (code, answer) = d.inputbox("Please provide a new value for "
410                     "the image property with name `%s':" % choice,
411                     init=session['metadata'][choice], width=INPUTBOX_WIDTH)
412             if code not in (d.DIALOG_CANCEL, d.DIALOG_ESC):
413                 value = answer.strip()
414                 if len(value) == 0:
415                     d.msgbox("Value cannot be empty!")
416                     continue
417                 else:
418                     session['metadata'][choice] = value
419         # ADD button
420         elif code == d.DIALOG_EXTRA:
421             add_property(session)
422
423
424 def delete_properties(session):
425     d = session['dialog']
426
427     choices = []
428     for (key, val) in session['metadata'].items():
429         choices.append((key, "%s" % val, 0))
430
431     (code, to_delete) = d.checklist("Choose which properties to delete:",
432                                     choices=choices)
433     count = len(to_delete)
434     # If the user exits with ESC or CANCEL, the returned tag list is empty.
435     for i in to_delete:
436         del session['metadata'][i]
437
438     if count > 0:
439         d.msgbox("%d image properties were deleted.", width=MSGBOX_WIDTH)
440
441
442 def exclude_task(session):
443     d = session['dialog']
444
445     choices = []
446     for (key, val) in session['metadata'].items():
447         choices.append((key, "%s" % val, 0))
448
449     (code, to_delete) = d.checklist("Choose which properties to delete:",
450                                     choices=choices)
451     count = len(to_delete)
452     # If the user exits with ESC or CANCEL, the returned tag list is empty.
453     for i in to_delete:
454         del session['metadata'][i]
455
456     if count > 0:
457         d.msgbox("%d image properties were deleted.", width=MSGBOX_WIDTH)
458
459
460 def deploy_menu(session):
461     d = session['dialog']
462
463     default_item = "View/Modify"
464     actions = {"View/Modify": modify_properties, "Delete": delete_properties}
465     while 1:
466         (code, choice) = d.menu(
467             "Choose one of the following or press <Back> to exit.",
468             width=MENU_WIDTH,
469             choices=[("View/Modify", "View/Modify image properties"),
470                      ("Delete", "Delete image properties"),
471                      ("Exclude", "Exclude configuration tasks from running")],
472         cancel="Back",
473         default_item=default_item,
474         title="Image Deployment Menu")
475
476         if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
477             break
478         elif choice in actions:
479             default_item = choice
480             actions[choice](session)
481
482
483 def main_menu(session):
484     d = session['dialog']
485     dev = session['device']
486     d.setBackgroundTitle("OS: %s, Distro: %s" % (dev.ostype, dev.distro))
487     actions = {"Deploy": deploy_menu,
488                "Register": kamaki_menu,
489                "Extract": extract_image}
490     default_item = "Customize"
491
492     while 1:
493         (code, choice) = d.menu(
494             "Choose one of the following or press <Exit> to exit.",
495             width=MENU_WIDTH,
496             choices=[("Customize", "Run various image customization tasks"),
497                      ("Deploy", "Configure ~okeanos image deployment options"),
498                      ("Register", "Register image to ~okeanos"),
499                      ("Extract", "Dump image to local file system"),
500                      ("Reset", "Reset everything and start over again"),
501                      ("Help", "Get help for using snf-image-creator")],
502             cancel="Exit",
503             default_item=default_item,
504             title="Image Creator for ~okeanos (snf-image-creator version %s)" %
505                   version)
506
507         if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
508             if confirm_exit(d):
509                 break
510             else:
511                 continue
512
513         if choice == "Reset":
514             if confirm_reset(d):
515                 d.infobox("Resetting snf-image-creator. Please wait...")
516                 raise Reset
517             else:
518                 continue
519         elif choice in actions:
520             actions[choice](session)
521
522
523 def select_file(d, media):
524     root = os.sep
525     while 1:
526         if media is not None:
527             if not os.path.exists(media):
528                 d.msgbox("The file you choose does not exist",
529                          width=MSGBOX_WIDTH)
530             else:
531                 break
532
533         (code, media) = d.fselect(root, 10, 50,
534                                  title="Please select input media")
535         if code in (d.DIALOG_CANCEL, d.DIALOG_ESC):
536             if confirm_exit(d, "You canceled the media selection dialog box."):
537                 sys.exit(0)
538             else:
539                 media = None
540                 continue
541
542     return media
543
544
545 def collect_metadata(dev, out):
546
547     out.output("Collecting image metadata...")
548     metadata = dev.meta
549     dev.mount(readonly=True)
550     cls = os_cls(dev.distro, dev.ostype)
551     image_os = cls(dev.root, dev.g, out)
552     dev.umount()
553     metadata.update(image_os.meta)
554     out.success("done")
555
556     return metadata
557
558
559 def image_creator(d):
560     basename = os.path.basename(sys.argv[0])
561     usage = "Usage: %s [input_media]" % basename
562     if len(sys.argv) > 2:
563         sys.stderr.write("%s\n" % usage)
564         return 1
565
566     if os.geteuid() != 0:
567         raise FatalError("You must run %s as root" % basename)
568
569     media = select_file(d, sys.argv[1] if len(sys.argv) == 2 else None)
570
571     out = InitializationOutput(d)
572     disk = Disk(media, out)
573
574     def signal_handler(signum, fram):
575         out.cleanup()
576         disk.cleanup()
577
578     signal.signal(signal.SIGINT, signal_handler)
579     try:
580         snapshot = disk.snapshot()
581         dev = disk.get_device(snapshot)
582
583         metadata = collect_metadata(dev, out)
584         out.cleanup()
585
586         # Make sure the signal handler does not call out.cleanup again
587         def dummy(self):
588             pass
589         instancemethod = type(InitializationOutput.cleanup)
590         out.cleanup = instancemethod(dummy, out, InitializationOutput)
591
592         session = {"dialog": d,
593                    "disk": disk,
594                    "snapshot": snapshot,
595                    "device": dev,
596                    "metadata": metadata}
597
598         main_menu(session)
599         d.infobox("Thank you for using snf-image-creator. Bye", width=53)
600     finally:
601         disk.cleanup()
602
603     return 0
604
605
606 def main():
607
608     d = dialog.Dialog(dialog="dialog")
609
610     # Add extra button in dialog library
611     dialog._common_args_syntax["extra_button"] = \
612         lambda enable: dialog._simple_option("--extra-button", enable)
613
614     dialog._common_args_syntax["extra_label"] = \
615         lambda string: ("--extra-label", string)
616
617     while 1:
618         try:
619             try:
620                 ret = image_creator(d)
621                 sys.exit(ret)
622             except FatalError as e:
623                 msg = textwrap.fill(str(e), width=70)
624                 d.infobox(msg, width=70, title="Fatal Error")
625                 sys.exit(1)
626         except Reset:
627             continue
628
629 # vim: set sta sts=4 shiftwidth=4 sw=4 et ai :