Added a verify() routine to the PLCConfiguration object in order to enforce
[myplc.git] / plc_config.py
1 #!/usr/bin/python
2 #
3 # Merge PlanetLab Central (PLC) configuration files into a variety of
4 # output formats. These files represent the global configuration for a
5 # PLC installation.
6 #
7 # Mark Huang <mlhuang@cs.princeton.edu>
8 # Copyright (C) 2006 The Trustees of Princeton University
9 #
10 # $Id$
11 #
12
13 import xml.dom.minidom
14 from StringIO import StringIO
15 import time
16 import re
17 import textwrap
18 import codecs
19 import os
20 import types
21
22
23 class ConfigurationException(Exception): pass
24
25 class PLCConfiguration:
26     """
27     Configuration file store. Optionally instantiate with a file path
28     or object:
29
30     plc = PLCConfiguration()
31     plc = PLCConfiguration(fileobj)
32     plc = PLCConfiguration("/etc/planetlab/plc_config.xml")
33
34     You may load() additional files later, which will be merged into
35     the current configuration:
36
37     plc.load("/etc/planetlab/local.xml")
38
39     You may also save() the configuration. If a file path or object is
40     not specified, the configuration will be written to the file path
41     or object that was first loaded.
42     
43     plc.save()
44     plc.save("/etc/planetlab/plc_config.xml")
45     """
46
47     def __init__(self, file = None):
48         impl = xml.dom.minidom.getDOMImplementation()
49         self._dom = impl.createDocument(None, "configuration", None)
50         self._variables = {}
51         self._packages = {}
52         self._files = []
53
54         if file is not None:
55             self.load(file)
56
57
58     def _get_text(self, node):
59         """
60         Get the text of a text node.
61         """
62
63         if node.firstChild and \
64            node.firstChild.nodeType == node.TEXT_NODE:
65             if node.firstChild.data is None:
66                 # Interpret simple presence of node as "", not NULL
67                 return ""
68             else:
69                 return node.firstChild.data
70
71         return None
72
73
74     def _get_text_of_child(self, parent, name):
75         """
76         Get the text of a (direct) child text node.
77         """
78
79         for node in parent.childNodes:
80             if node.nodeType == node.ELEMENT_NODE and \
81                node.tagName == name:
82                 return self._get_text(node)
83
84         return None
85
86
87     def _set_text(self, node, data):
88         """
89         Set the text of a text node.
90         """
91
92         if node.firstChild and \
93            node.firstChild.nodeType == node.TEXT_NODE:
94             if data is None:
95                 node.removeChild(node.firstChild)
96             else:
97                 node.firstChild.data = data
98         elif data is not None:
99             text = TrimText()
100             text.data = data
101             node.appendChild(text)
102
103
104     def _set_text_of_child(self, parent, name, data):
105         """
106         Set the text of a (direct) child text node.
107         """
108
109         for node in parent.childNodes:
110             if node.nodeType == node.ELEMENT_NODE and \
111                node.tagName == name:
112                 self._set_text(node, data)
113                 return
114
115         child = TrimTextElement(name)
116         self._set_text(child, data)
117         parent.appendChild(child)
118
119
120     def _category_element_to_dict(self, category_element):
121         """
122         Turn a <category> element into a dictionary of its attributes
123         and child text nodes.
124         """
125
126         category = {}
127         category['id'] = category_element.getAttribute('id').lower()
128         for node in category_element.childNodes:
129             if node.nodeType == node.ELEMENT_NODE and \
130                node.tagName in ['name', 'description']:
131                 category[node.tagName] = self._get_text_of_child(category_element, node.tagName)
132         category['element'] = category_element
133
134         return category
135
136
137     def _variable_element_to_dict(self, variable_element):
138         """
139         Turn a <variable> element into a dictionary of its attributes
140         and child text nodes.
141         """
142
143         variable = {}
144         variable['id'] = variable_element.getAttribute('id').lower()
145         if variable_element.hasAttribute('type'):
146             variable['type'] = variable_element.getAttribute('type')
147         for node in variable_element.childNodes:
148             if node.nodeType == node.ELEMENT_NODE and \
149                node.tagName in ['name', 'value', 'description']:
150                 variable[node.tagName] = self._get_text_of_child(variable_element, node.tagName)
151         variable['element'] = variable_element
152
153         return variable
154
155
156     def _group_element_to_dict(self, group_element):
157         """
158         Turn a <group> element into a dictionary of its attributes
159         and child text nodes.
160         """
161
162         group = {}
163         for node in group_element.childNodes:
164             if node.nodeType == node.ELEMENT_NODE and \
165                node.tagName in ['id', 'name', 'default', 'description', 'uservisible']:
166                 group[node.tagName] = self._get_text_of_child(group_element, node.tagName)
167         group['element'] = group_element
168
169         return group
170
171
172     def _packagereq_element_to_dict(self, packagereq_element):
173         """
174         Turns a <packagereq> element into a dictionary of its attributes
175         and child text nodes.
176         """
177
178         package = {}
179         if packagereq_element.hasAttribute('type'):
180             package['type'] = packagereq_element.getAttribute('type')
181         package['name'] = self._get_text(packagereq_element)
182         package['element'] = packagereq_element
183
184         return package
185
186
187     def load(self, file = "/etc/planetlab/plc_config.xml"):
188         """
189         Merge file into configuration store.
190         """
191
192         dom = xml.dom.minidom.parse(file)
193         if type(file) in types.StringTypes:
194             self._files.append(os.path.abspath(file))
195
196         # Parse <variables> section
197         for variables_element in dom.getElementsByTagName('variables'):
198             for category_element in variables_element.getElementsByTagName('category'):
199                 category = self._category_element_to_dict(category_element)
200                 self.set(category, None)
201
202                 for variablelist_element in category_element.getElementsByTagName('variablelist'):
203                     for variable_element in variablelist_element.getElementsByTagName('variable'):
204                         variable = self._variable_element_to_dict(variable_element)
205                         self.set(category, variable)
206
207         # Parse <comps> section
208         for comps_element in dom.getElementsByTagName('comps'):
209             for group_element in comps_element.getElementsByTagName('group'):
210                 group = self._group_element_to_dict(group_element)
211                 self.add_package(group, None)
212
213                 for packagereq_element in group_element.getElementsByTagName('packagereq'):
214                     package = self._packagereq_element_to_dict(packagereq_element)
215                     self.add_package(group, package)
216
217
218     def save(self, file = None):
219         """
220         Write configuration store to file.
221         """
222
223         if file is None:
224             if self._files:
225                 file = self._files[0]
226             else:
227                 file = "/etc/planetlab/plc_config.xml"
228
229         if type(file) in types.StringTypes:
230             fileobj = open(file, 'w')
231         else:
232             fileobj = file
233
234         fileobj.seek(0)
235         fileobj.write(self.output_xml())
236         fileobj.truncate()
237
238         fileobj.close()
239
240     def verify(self, default, read):
241         """ Confirm that the existing configuration is consistent according to
242         the checks below.
243
244             It looks for filled-in values in the order of, local object (self),
245             followed by cread (read values), and finally default values.
246
247         Arguments: 
248
249             None
250
251         Returns:
252
253             None.  If an exception is found, ConfigurationException is raised.
254
255         """
256
257         maint_user = self.get('plc_api', 'maintenance_user')
258         if maint_user == (None, None):
259             maint_user = read.get('plc_api', 'maintenance_user')
260             if maint_user == (None, None):
261                 maint_user = default.get('plc_api', 'maintenance_user')
262
263         root_user = self.get('plc', 'root_user')
264         if root_user == (None, None):
265             root_user = read.get('plc', 'root_user')
266             if root_user == (None, None):
267                 root_user = default.get('plc', 'root_user')
268
269         muser= maint_user[1]['value']
270         ruser= root_user[1]['value']
271
272         if muser == ruser:
273             raise ConfigurationException("The Maintenance Account email address cannot be the same as the Root User email address")
274         return
275
276
277     def get(self, category_id, variable_id):
278         """
279         Get the specified variable in the specified category.
280
281         Arguments:
282
283         category_id = unique category identifier (e.g., 'plc_www')
284         variable_id = unique variable identifier (e.g., 'port')
285
286         Returns:
287
288         variable = { 'id': "variable_identifier",
289                      'type': "variable_type",
290                      'value': "variable_value",
291                      'name': "Variable name",
292                      'description': "Variable description" }
293         """
294
295         if self._variables.has_key(category_id.lower()):
296             (category, variables) = self._variables[category_id]
297             if variables.has_key(variable_id.lower()):
298                 variable = variables[variable_id]
299             else:
300                 variable = None
301         else:
302             category = None
303             variable = None
304
305         return (category, variable)
306
307
308     def delete(self, category_id, variable_id):
309         """
310         Delete the specified variable from the specified category. If
311         variable_id is None, deletes all variables from the specified
312         category as well as the category itself.
313
314         Arguments:
315
316         category_id = unique category identifier (e.g., 'plc_www')
317         variable_id = unique variable identifier (e.g., 'port')
318         """
319
320         if self._variables.has_key(category_id.lower()):
321             (category, variables) = self._variables[category_id]
322             if variable_id is None:
323                 category['element'].parentNode.removeChild(category['element'])
324                 del self._variables[category_id]
325             elif variables.has_key(variable_id.lower()):
326                 variable = variables[variable_id]
327                 variable['element'].parentNode.removeChild(variable['element'])
328                 del variables[variable_id]
329
330
331     def set(self, category, variable):
332         """
333         Add and/or update the specified variable. The 'id' fields are
334         mandatory. If a field is not specified and the category and/or
335         variable already exists, the field will not be updated. If
336         'variable' is None, only adds and/or updates the specified
337         category.
338
339         Arguments:
340
341         category = { 'id': "category_identifier",
342                      'name': "Category name",
343                      'description': "Category description" }
344
345         variable = { 'id': "variable_identifier",
346                      'type': "variable_type",
347                      'value': "variable_value",
348                      'name': "Variable name",
349                      'description': "Variable description" }
350         """
351
352         if not category.has_key('id') or type(category['id']) not in types.StringTypes:
353             return
354         
355         category_id = category['id'].lower()
356
357         if self._variables.has_key(category_id):
358             # Existing category
359             (old_category, variables) = self._variables[category_id]
360
361             # Merge category attributes
362             for tag in ['name', 'description']:
363                 if category.has_key(tag):
364                     old_category[tag] = category[tag]
365                     self._set_text_of_child(old_category['element'], tag, category[tag])
366
367             category_element = old_category['element']
368         else:
369             # Merge into DOM
370             category_element = self._dom.createElement('category')
371             category_element.setAttribute('id', category_id)
372             for tag in ['name', 'description']:
373                 if category.has_key(tag):
374                     self._set_text_of_child(category_element, tag, category[tag])
375
376             if self._dom.documentElement.getElementsByTagName('variables'):
377                 variables_element = self._dom.documentElement.getElementsByTagName('variables')[0]
378             else:
379                 variables_element = self._dom.createElement('variables')
380                 self._dom.documentElement.appendChild(variables_element)
381             variables_element.appendChild(category_element)
382
383             # Cache it
384             category['element'] = category_element
385             variables = {}
386             self._variables[category_id] = (category, variables)
387
388         if variable is None or not variable.has_key('id') or type(variable['id']) not in types.StringTypes:
389             return
390
391         variable_id = variable['id'].lower()
392
393         if variables.has_key(variable_id):
394             # Existing variable
395             old_variable = variables[variable_id]
396
397             # Merge variable attributes
398             for attribute in ['type']:
399                 if variable.has_key(attribute):
400                     old_variable[attribute] = variable[attribute]
401                     old_variable['element'].setAttribute(attribute, variable[attribute])
402             for tag in ['name', 'value', 'description']:
403                 if variable.has_key(tag):
404                     old_variable[tag] = variable[tag]
405                     self._set_text_of_child(old_variable['element'], tag, variable[tag])
406         else:
407             # Merge into DOM
408             variable_element = self._dom.createElement('variable')
409             variable_element.setAttribute('id', variable_id)
410             for attribute in ['type']:
411                 if variable.has_key(attribute):
412                     variable_element.setAttribute(attribute, variable[attribute])
413             for tag in ['name', 'value', 'description']:
414                 if variable.has_key(tag):
415                     self._set_text_of_child(variable_element, tag, variable[tag])
416                 
417             if category_element.getElementsByTagName('variablelist'):
418                 variablelist_element = category_element.getElementsByTagName('variablelist')[0]
419             else:
420                 variablelist_element = self._dom.createElement('variablelist')
421                 category_element.appendChild(variablelist_element)
422             variablelist_element.appendChild(variable_element)
423
424             # Cache it
425             variable['element'] = variable_element
426             variables[variable_id] = variable
427
428
429     def locate_varname (self, varname):
430         """
431         Locates category and variable from a variable's (shell) name
432
433         Returns:
434         (variable, category) when found
435         (None, None) otherwise
436         """
437         
438         for (category_id, (category, variables)) in self._variables.iteritems():
439             for variable in variables.values():
440                 (id, name, value, comments) = self._sanitize_variable(category_id, variable)
441                 if (id == varname):
442                     return (category,variable)
443         return (None,None)
444
445     def get_package(self, group_id, package_name):
446         """
447         Get the specified package in the specified package group.
448
449         Arguments:
450
451         group_id - unique group id (e.g., 'plc')
452         package_name - unique package name (e.g., 'postgresql')
453
454         Returns:
455
456         package = { 'name': "package_name",
457                     'type': "mandatory|optional" }
458         """
459
460         if self._packages.has_key(group_id.lower()):
461             (group, packages) = self._packages[group_id]
462             if packages.has_key(package_name):
463                 package = packages[package_name]
464             else:
465                 package = None
466         else:
467             group = None
468             package = None
469
470         return (group, package)
471
472
473     def delete_package(self, group_id, package_name):
474         """
475         Deletes the specified variable from the specified category. If
476         variable_id is None, deletes all variables from the specified
477         category as well as the category itself.
478
479         Arguments:
480
481         group_id - unique group id (e.g., 'plc')
482         package_name - unique package name (e.g., 'postgresql')
483         """
484
485         if self._packages.has_key(group_id):
486             (group, packages) = self._packages[group_id]
487             if package_name is None:
488                 group['element'].parentNode.removeChild(group['element'])
489                 del self._packages[group_id]
490             elif packages.has_key(package_name.lower()):
491                 package = packages[package_name]
492                 package['element'].parentNode.removeChild(package['element'])
493                 del packages[package_name]
494
495
496     def add_package(self, group, package):
497         """
498         Add and/or update the specified package. The 'id' and 'name'
499         fields are mandatory. If a field is not specified and the
500         package or group already exists, the field will not be
501         updated. If package is None, only adds/or updates the
502         specified group.
503
504         Arguments:
505
506         group = { 'id': "group_identifier",
507                   'name': "Group name",
508                   'default': "true|false",
509                   'description': "Group description",
510                   'uservisible': "true|false" }
511
512         package = { 'name': "package_name",
513                     'type': "mandatory|optional" }
514         """
515
516         if not group.has_key('id'):
517             return
518
519         group_id = group['id']
520
521         if self._packages.has_key(group_id):
522             # Existing group
523             (old_group, packages) = self._packages[group_id]
524
525             # Merge group attributes
526             for tag in ['id', 'name', 'default', 'description', 'uservisible']:
527                 if group.has_key(tag):
528                     old_group[tag] = group[tag]
529                     self._set_text_of_child(old_group['element'], tag, group[tag])
530
531             group_element = old_group['element']
532         else:
533             # Merge into DOM
534             group_element = self._dom.createElement('group')
535             for tag in ['id', 'name', 'default', 'description', 'uservisible']:
536                 if group.has_key(tag):
537                     self._set_text_of_child(group_element, tag, group[tag])
538
539             if self._dom.documentElement.getElementsByTagName('comps'):
540                 comps_element = self._dom.documentElement.getElementsByTagName('comps')[0]
541             else:
542                 comps_element = self._dom.createElement('comps')
543                 self._dom.documentElement.appendChild(comps_element)
544             comps_element.appendChild(group_element)
545
546             # Cache it
547             group['element'] = group_element
548             packages = {}
549             self._packages[group_id] = (group, packages)
550
551         if package is None or not package.has_key('name'):
552             return
553
554         package_name = package['name']
555         if packages.has_key(package_name):
556             # Existing package
557             old_package = packages[package_name]
558
559             # Merge variable attributes
560             for attribute in ['type']:
561                 if package.has_key(attribute):
562                     old_package[attribute] = package[attribute]
563                     old_package['element'].setAttribute(attribute, package[attribute])
564         else:
565             # Merge into DOM
566             packagereq_element = TrimTextElement('packagereq')
567             self._set_text(packagereq_element, package_name)
568             for attribute in ['type']:
569                 if package.has_key(attribute):
570                     packagereq_element.setAttribute(attribute, package[attribute])
571                 
572             if group_element.getElementsByTagName('packagelist'):
573                 packagelist_element = group_element.getElementsByTagName('packagelist')[0]
574             else:
575                 packagelist_element = self._dom.createElement('packagelist')
576                 group_element.appendChild(packagelist_element)
577             packagelist_element.appendChild(packagereq_element)
578
579             # Cache it
580             package['element'] = packagereq_element
581             packages[package_name] = package
582
583
584     def variables(self):
585         """
586         Return all variables.
587
588         Returns:
589
590         variables = { 'category_id': (category, variablelist) }
591
592         category = { 'id': "category_identifier",
593                      'name': "Category name",
594                      'description': "Category description" }
595
596         variablelist = { 'variable_id': variable }
597
598         variable = { 'id': "variable_identifier",
599                      'type': "variable_type",
600                      'value': "variable_value",
601                      'name': "Variable name",
602                      'description': "Variable description" }
603         """
604
605         return self._variables
606
607
608     def packages(self):
609         """
610         Return all packages.
611
612         Returns:
613
614         packages = { 'group_id': (group, packagelist) }
615
616         group = { 'id': "group_identifier",
617                   'name': "Group name",
618                   'default': "true|false",
619                   'description': "Group description",
620                   'uservisible': "true|false" }
621
622         packagelist = { 'package_name': package }
623
624         package = { 'name': "package_name",
625                     'type': "mandatory|optional" }
626         """
627
628         return self._packages
629
630
631     def _sanitize_variable(self, category_id, variable):
632         assert variable.has_key('id')
633         # Prepend variable name with category label
634         id = category_id + "_" + variable['id']
635         # And uppercase it
636         id = id.upper()
637
638         if variable.has_key('type'):
639             type = variable['type']
640         else:
641             type = None
642
643         if variable.has_key('name'):
644             name = variable['name']
645         else:
646             name = None
647
648         if variable.has_key('value') and variable['value'] is not None:
649             value = variable['value']
650             if type == "int" or type == "double":
651                 # bash, Python, and PHP do not require that numbers be quoted
652                 pass
653             elif type == "boolean":
654                 # bash, Python, and PHP can all agree on 0 and 1
655                 if value == "true":
656                     value = "1"
657                 else:
658                     value = "0"
659             else:
660                 # bash, Python, and PHP all support strong single quoting
661                 value = "'" + value.replace("'", "\\'") + "'"
662         else:
663             value = None
664
665         if variable.has_key('description') and variable['description'] is not None:
666             description = variable['description']
667             # Collapse consecutive whitespace
668             description = re.sub(r'\s+', ' ', description)
669             # Wrap comments at 70 columns
670             wrapper = textwrap.TextWrapper()
671             comments = wrapper.wrap(description)
672         else:
673             comments = None
674
675         return (id, name, value, comments)
676
677
678     def _header(self):
679         header = """
680 DO NOT EDIT. This file was automatically generated at
681 %s from:
682
683 %s
684 """ % (time.asctime(), os.linesep.join(self._files))
685
686         # Get rid of the surrounding newlines
687         return header.strip().split(os.linesep)
688
689
690     def output_shell(self, show_comments = True, encoding = "utf-8"):
691         """
692         Return variables as a shell script.
693         """
694
695         buf = codecs.lookup(encoding)[3](StringIO())
696         buf.writelines(["# " + line + os.linesep for line in self._header()])
697
698         for (category_id, (category, variables)) in self._variables.iteritems():
699             for variable in variables.values():
700                 (id, name, value, comments) = self._sanitize_variable(category_id, variable)
701                 if show_comments:
702                     buf.write(os.linesep)
703                     if name is not None:
704                         buf.write("# " + name + os.linesep)
705                     if comments is not None:
706                         buf.writelines(["# " + line + os.linesep for line in comments])
707                 # bash does not have the concept of NULL
708                 if value is not None:
709                     buf.write(id + "=" + value + os.linesep)
710
711         return buf.getvalue()
712
713
714     def output_php(self, encoding = "utf-8"):
715         """
716         Return variables as a PHP script.
717         """
718
719         buf = codecs.lookup(encoding)[3](StringIO())
720         buf.write("<?php" + os.linesep)
721         buf.writelines(["// " + line + os.linesep for line in self._header()])
722
723         for (category_id, (category, variables)) in self._variables.iteritems():
724             for variable in variables.values():
725                 (id, name, value, comments) = self._sanitize_variable(category_id, variable)
726                 buf.write(os.linesep)
727                 if name is not None:
728                     buf.write("// " + name + os.linesep)
729                 if comments is not None:
730                     buf.writelines(["// " + line + os.linesep for line in comments])
731                 if value is None:
732                     value = 'NULL'
733                 buf.write("define('%s', %s);" % (id, value) + os.linesep)
734
735         buf.write("?>" + os.linesep)
736
737         return buf.getvalue()
738
739
740     def output_xml(self, encoding = "utf-8"):
741         """
742         Return variables in original XML format.
743         """
744
745         buf = codecs.lookup(encoding)[3](StringIO())
746         self._dom.writexml(buf, addindent = "  ", indent = "", newl = "\n", encoding = encoding)
747
748         return buf.getvalue()
749
750
751     def output_variables(self, encoding = "utf-8"):
752         """
753         Return list of all variable names.
754         """
755
756         buf = codecs.lookup(encoding)[3](StringIO())
757
758         for (category_id, (category, variables)) in self._variables.iteritems():
759             for variable in variables.values():
760                 (id, name, value, comments) = self._sanitize_variable(category_id, variable)
761                 buf.write(id + os.linesep)
762
763         return buf.getvalue()
764
765
766     def output_packages(self, encoding = "utf-8"):
767         """
768         Return list of all packages.
769         """
770
771         buf = codecs.lookup(encoding)[3](StringIO())
772
773         for (group, packages) in self._packages.values():
774             buf.write(os.linesep.join(packages.keys()))
775
776         if buf.tell():
777             buf.write(os.linesep)
778
779         return buf.getvalue()
780
781
782     def output_groups(self, encoding = "utf-8"):
783         """
784         Return list of all package group names.
785         """
786
787         buf = codecs.lookup(encoding)[3](StringIO())
788
789         for (group, packages) in self._packages.values():
790             buf.write(group['name'] + os.linesep)
791
792         return buf.getvalue()
793
794
795     def output_comps(self, encoding = "utf-8"):
796         """
797         Return <comps> section of configuration.
798         """
799
800         if self._dom is None or \
801            not self._dom.getElementsByTagName("comps"):
802             return
803         comps = self._dom.getElementsByTagName("comps")[0]
804
805         impl = xml.dom.minidom.getDOMImplementation()
806         doc = impl.createDocument(None, "comps", None)
807
808         buf = codecs.lookup(encoding)[3](StringIO())
809
810         # Pop it off the DOM temporarily
811         parent = comps.parentNode
812         parent.removeChild(comps)
813
814         doc.replaceChild(comps, doc.documentElement)
815         doc.writexml(buf, encoding = encoding)
816
817         # Put it back
818         parent.appendChild(comps)
819
820         return buf.getvalue()
821
822
823 # xml.dom.minidom.Text.writexml adds surrounding whitespace to textual
824 # data when pretty-printing. Override this behavior.
825 class TrimText(xml.dom.minidom.Text):
826     def writexml(self, writer, indent="", addindent="", newl=""):
827         xml.dom.minidom.Text.writexml(self, writer, "", "", "")
828
829
830 class TrimTextElement(xml.dom.minidom.Element):
831     def writexml(self, writer, indent="", addindent="", newl=""):
832         writer.write(indent)
833         xml.dom.minidom.Element.writexml(self, writer, "", "", "")
834         writer.write(newl)
835
836
837 if __name__ == '__main__':
838     import sys
839     if len(sys.argv) > 1 and sys.argv[1] in ['build', 'install', 'uninstall']:
840         from distutils.core import setup
841         setup(py_modules=["plc_config"])