source: fedd/compose.py @ 301e941

axis_examplecompt_changesinfo-opsversion-3.01version-3.02
Last change on this file since 301e941 was 4b68c58, checked in by Ted Faber <faber@…>, 15 years ago

COnsistency no one cares about but me. plus 0 exit status.

  • Property mode set to 100644
File size: 21.6 KB
Line 
1#!/usr/local/bin/python
2
3import sys
4import re
5import os
6import random
7import copy
8
9import xml.parsers.expat
10
11from optparse import OptionParser, OptionValueError
12from federation.remote_service import service_caller
13from federation.service_error import service_error
14from federation import topdl
15
16class constraint:
17    """
18    This is mainly a struct to hold constraint fields and convert to XML output
19    """
20    def __init__(self, name=None, required=False, accepts=None, provides=None,
21            topology=None, match=None):
22        self.name = name
23        self.required = required
24        self.accepts = accepts or []
25        self.provides = provides or []
26        self.topology = None
27        self.match = match
28
29    def __str__(self):
30        return "%s:%s:%s:%s" % (self.name, self.required,
31                ",".join(self.provides), ",".join(self.accepts))
32
33    def to_xml(self):
34        rv = "<constraint>"
35        rv += "<name>%s</name>" % self.name
36        rv += "<required>%s</required>" % self.required
37        for p in self.provides:
38            rv += "<provides>%s</provides>" % p
39        for a in self.accepts:
40            rv += "<accepts>%s</accepts>" % a
41        rv+= "</constraint>"
42        return rv
43
44def constraints_from_xml(string=None, file=None, filename=None, 
45        top="constraints"):
46    """
47    Pull constraints from an xml file.  Only constraints in the top element are
48    recognized.  A constraint consists of a constraint element with one name,
49    one required, and multiple accepts nd provides elements.  Each contains a
50    string.  A list of constraints is returned.
51    """
52    class parser:
53        """
54        A little class to encapsulate some state used to parse the constraints.
55        The methods are all bound to the analogous handlers in an XML parser.
56        It collects the constraints in self.constraint.
57        """
58        def __init__(self, top):
59            self.top = top
60            self.constraints = [ ]
61            self.chars = None
62            self.current = None
63            self.in_top = False
64       
65        def start_element(self, name, attrs):
66            # Clear any collected strings (from inside elements)
67            self.chars = None
68            self.key = str(name)
69           
70            # See if we've entered the containing context
71            if name == self.top:
72                self.in_top = True
73
74            # Entering a constraint, create the object which also acts as a
75            # flag to indicate we're collecting constraint data.
76            if self.in_top:
77                if name == 'constraint':
78                    self.current = constraint()
79
80        def end_element(self, name):
81            if self.current:
82                # In a constraint and leaving an element.  Clean up any string
83                # we've collected and process elements we know.
84                if self.chars is not None:
85                    self.chars = self.chars.strip()
86
87                if name == 'required':
88                    if self.chars is None:
89                        self.current.required = False
90                    else:
91                        self.current.required = (self.chars.lower() == 'true')
92                elif name == 'name':
93                    self.current.name = self.chars
94                elif name == 'accepts':
95                    self.current.accepts.append(self.chars)
96                elif name == 'provides':
97                    self.current.provides.append(self.chars)
98                elif name == 'constraint':
99                    # Leaving this constraint.  Record it and clear the flag
100                    self.constraints.append(self.current)
101                    self.current = None
102                else:
103                    print >>sys.stderr, \
104                            "Unexpected element in constraint: %s" % name
105            elif name == self.top:
106                # We've left the containing context
107                self.in_top = False
108
109
110        def char_data(self, data):
111            # Collect strings if we're in the overall context
112            if self.in_top:
113                if self.chars is None: self.chars = data
114                else: self.chars += data
115
116    # Beginning of constraints_from_xml.  Connect up the parser and call it
117    # properly for the kind of input supplied.
118    p = parser(top=top)
119    xp = xml.parsers.expat.ParserCreate()
120
121    xp.StartElementHandler = p.start_element
122    xp.EndElementHandler = p.end_element
123    xp.CharacterDataHandler = p.char_data
124
125    if len([x for x in (string, filename, file) if x is not None])!= 1:
126        raise RuntimeError("Exactly one one of file, filename and string " + \
127                "must be set")
128    elif filename:
129        f = open(filename, "r")
130        xp.ParseFile(f)
131        f.close()
132    elif file:
133        xp.ParseFile(file)
134    elif string:
135        xp.Parse(string, isfinal=True)
136    else:
137        return []
138
139    return p.constraints
140
141
142class ComposeOptionParser(OptionParser):
143    """
144    This class encapsulates the options to this script in one place.  It also
145    holds the callback for the multifile choice.
146    """
147    def __init__(self):
148        OptionParser.__init__(self)
149        self.add_option('--url', dest='url', default="http://localhost:13235",
150                help='url of ns2 to topdl service')
151        self.add_option('--certfile', dest='cert', default=None,
152                help='Certificate to use as identity')
153        self.add_option('--seed', dest='seed', type='int', default=None,
154                help='Random number seed')
155        self.add_option('--multifile', dest='files', default=[], type='string', 
156                action='callback', callback=self.multi_callback, 
157                help="Include file multiple times")
158        self.add_option('--output', dest='outfile', default=None,
159                help='Output file name')
160        self.add_option("--format", dest="format", type="choice", 
161                choices=("xml", "topdl", "tcl", "ns"),
162                help="Output file format")
163        self.add_option('--add_testbeds', dest='add_testbeds', default=False,
164                action='store_true',
165                help='add testbed attributes to each component')
166        self.add_option('--lax', dest='lax', default=False,
167                action='store_true',
168                help='allow solutions where unrequired constraints fail')
169        self.add_option('--same_node', dest='same_node', action='store_true',
170                default=False, 
171                help='Allow loops to the same node to be created.')
172        self.add_option('--same_topology', dest='same_topo',
173                action='store_true', default=False, 
174                help='Allow links within the same topology to be created.')
175        self.add_option('--same_pair', dest='multi_pair',
176                action='store_true', default=False, 
177                help='Allow multiple links between the same nodes " + \
178                        "to be created.')
179
180    @staticmethod
181    def multi_callback(option, opt_str, value, parser):
182        """
183        Parse a --multifile command line option.  The parameter is of the form
184        filename,count.  This splits the argument at the rightmost comma and
185        inserts the filename, count tuple into the "files" option.  It handles
186        a couple error cases, too.
187        """
188        idx = value.rfind(',')
189        if idx != -1:
190            try:
191                parser.values.files.append((value[0:idx], int(value[idx+1:])))
192            except ValueError, e:
193                raise OptionValueError(
194                        "Can't convert %s to int in multifile (%s)" % \
195                                (value[idx+1:], value))
196        else:
197            raise OptionValueError(
198                    "Bad format (need a comma) for multifile: %s" % value)
199
200def warn(msg):
201    """
202    Exactly what you think.  Print a message to stderr
203    """
204    print >>sys.stderr, msg
205
206
207def make_new_name(names, prefix="name"):
208    """
209    Generate an identifier not present in names by appending an integer to
210    prefix.  The new name is inserted into names and returned.
211    """
212    i = 0
213    n = "%s%d" % (prefix,i)
214    while n in names:
215        i += 1
216        n = "%s%d" % (prefix,i)
217    names.add(n)
218    return n
219
220def localize_names(top, names, marks):
221    """
222    Take a topology and rename any substrates or elements that share a name
223    with an existing computer or substrate.  Keep the original name as a
224    localized_name attribute.  In addition, remap any constraints or interfaces
225    that point to the old name over to the new one.  Constraints are found in
226    the marks dict, indexed by node name.  Those constraints name attributes
227    have already been converted to triples (node name, interface name,
228    topology) so only the node name needs to be changed.
229    """
230    sub_map = { }
231    for s in top.substrates:
232        s.set_attribute('localized_name', s.name)
233        if s.name in names:
234            sub_map[s.name] = n = make_new_name(names, "substrate")
235            s.name = n
236        else:
237            names.add(s.name)
238
239    for e in [ e for e in top.elements if isinstance(e, topdl.Computer)]:
240        e.set_attribute('localized_name', e.name)
241        if e.name in names:
242            nn= make_new_name(names, "node")
243            for c in marks.get(e.name, []):
244                c.name = nn
245            e.name = nn
246        else:
247            names.add(e.name)
248
249        # Interface mapping.  The list comprehension creates a list of
250        # substrate names where each element in the list is replaced by the
251        # entry in sub_map indexed by it if present and left alone otherwise.
252        for i in e.interface:
253            i.substrate = [ sub_map.get(ii, ii) for ii in i.substrate ]
254
255def meet_constraints(candidates, provides, accepts, 
256        same_node=False, same_topo=False, multi_pair=False):
257    """
258    Try to meet all the constraints in candidates using the information in the
259    provides and accepts dicts (which index constraints that provide or accept
260    the given attribute). A constraint is met if it can be matched with another
261    constraint that provides an attribute that the first constraint accepts.
262    Only one match per pair is allowed, and we always prefer matching a
263    required constraint to an unreqired one.  If all the candidates can be
264    matches, return True and return False otherwise.
265    """
266    got_all = True
267    node_match = { }
268    for c in candidates:
269        if not c.match:
270            rmatch = None   # Match to a required constraint
271            umatch = None   # Match to an unrequired constraint
272            for a in c.accepts:
273                for can in provides.get(a,[]):
274                    # A constraint cannot satisfy itself nor can it match
275                    # multiple times.
276                    if can != c and not can.match:
277                        # Unless same_node is true disallow nodes satisfying
278                        # their own constraints.
279                        if not same_node and can.name == c.name:
280                            continue
281                        # Unless same_topo is true, exclude nodes in the same
282                        # topology.
283                        if not same_topo and can.topology == c.topology:
284                            continue
285                        # Don't allow multiple matches between the same nodes
286                        if not multi_pair and c.name in node_match and \
287                                can.name in node_match[c.name]:
288                            continue
289                        # Now check that can also accepts c
290                        for ca in can.accepts:
291                            if ca in c.provides:
292                                if can.required: rmatch = can
293                                else: umatch = can
294                                break
295                       
296                        # Within providers, prefer matches against required
297                        # composition points.
298                        if rmatch:
299                            break
300                # Within acceptance categories, prefer matches against required
301                # composition points
302                if rmatch:
303                    break
304
305            # Move the better match over to the match variable
306            if rmatch: match = rmatch
307            elif umatch: match = umatch
308            else: match = None
309
310            # Done checking all possible matches.  Record the match or note an
311            # unmatched candidate.
312            if match:
313                match.match = c
314                c.match = match
315                # Note the match of the nodes
316                for a, b in ((c.name, match.name), (match.name, c.name)):
317                    if a in node_match: node_match[a].append(b)
318                    else: node_match[a] = [ b]
319            else:
320                got_all = False
321    return got_all
322
323def randomize_constraint_order(indexes):
324    """
325    Randomly reorder the lists of constraints that provides and accepts hold.
326    """
327    if not isinstance(indexes, tuple):
328        indexes = (indexes,)
329
330    for idx in indexes:
331        for k in idx.keys():
332            random.shuffle(idx[k])
333
334def remote_ns2topdl(uri, desc, cert):
335    """
336    Call a remote service to convert the ns2 to topdl (and in fact all the way
337    to a topdl.Topology.
338    """
339
340    req = { 'description' : { 'ns2description': desc }, }
341
342    r = service_caller('Ns2Topdl')(uri, req, cert)
343
344    if r.has_key('Ns2TopdlResponseBody'):
345        r = r['Ns2TopdlResponseBody']
346        ed = r.get('experimentdescription', None)
347        if 'topdldescription' in ed:
348            return topdl.Topology(**ed['topdldescription'])
349        else:
350            return None
351    else:
352        return None
353
354def connect_composition_points(top, contraints, names):
355    """
356    top is a topology containing copies of all the topologies represented in
357    the contsraints, flattened into one name space.  This routine inserts the
358    additional substrates required to interconnect the topology as described by
359    the constraints.  After the links are made, unused connection points are
360    purged.
361    """
362    done = set()
363    for c in constraints:
364        if c not in done and c.match:
365            # c is an unprocessed matched constraint.  Create a substrate
366            # and attach it to the interfaces named by c and c.match.
367            sn = make_new_name(names, "sub")
368            s = topdl.Substrate(name=sn)
369
370            # These are the nodes that need to be connected.  Put an new
371            # interface on each one and hook them to the new substrate.
372            for e in [ e for e in top.elements \
373                    if isinstance(e, topdl.Computer) \
374                        and e.name in (c.name, c.match.name)]:
375                ii = make_new_name(set([x.name for x in e.interface]), "inf")
376                e.interface.append(
377                        topdl.Interface(substrate=[sn], name=ii, element=e))
378
379
380            # c and c.match have been processed, so add them to the done set,
381            # and add the substrate
382            top.substrates.append(s)
383            done.add(c)
384            done.add(c.match)
385
386    top.incorporate_elements()
387
388def import_ns2_constraints(contents):
389    """
390    Contents is a list containing the lines of an annotated ns2 file.  This
391    routine extracts the constraint comment lines and convertes them into
392    constraints in the namespace of the tcl experiment, as well as inserting
393    them in the accepts and provides indices.
394
395    Constraints are given in lines of the form
396        name:required:provides:accepts
397    where name is the name of the node in the current topology, if the second
398    field is "required" this is a required constraint, a list of attributes
399    that this connection point provides and a list that it accepts.  The
400    provides and accepts lists are comma-separated.  The constraint is added to
401    the marks dict under the name key and that dict is returned.
402    """
403
404    const_re = re.compile("\s*#\s*COMPOSITION:\s*([^:]+:[^:]+:.*)")
405
406    constraints = [ ]
407    for l in contents:
408        m = const_re.search(l)
409        if m:
410            exp = re.sub('\s', '', m.group(1))
411            nn, r, p, a = exp.split(":")
412            if nn.find('(') != -1:
413                # Convert array references into topdl names
414                nn = re.sub('\\((\\d)\\)', '-\\1', nn)
415            p = p.split(",")
416            a = a.split(",")
417            constraints.append(constraint(name=nn, required=(r == 'required'),
418                    provides=p, accepts=a))
419    return constraints
420
421def import_ns2_component(fn):
422    """
423    Pull a composition component in from an ns2 description.  The Constraints
424    are parsed from the comments using import_ns2_constraints and the topology
425    is created using a call to a fedd exporting the Ns2Topdl function.  A
426    topdl.Topology object rempresenting the component's topology and a dict
427    mapping constraints from the components names to the conttraints is
428    returned.  If either the file read or the conversion fails, appropriate
429    Exceptions are thrown.
430    """
431    f = open(fn, "r")
432    contents = [ l for l in f ]
433
434    marks = import_ns2_constraints(contents)
435    top = remote_ns2topdl(opts.url, "".join(contents), cert)
436    if not top:
437        raise RuntimeError("Cannot create topology from: %s" % fn)
438
439    return (top, marks)
440
441def import_xml_component(fn):
442    """
443    Pull a component in from a topdl description.
444    """
445    return (topdl.topology_from_xml(filename=fn, top='experiment'), 
446            constraints_from_xml(filename=fn, top='constraints'))
447
448def index_constraints(constraints, provides, accepts, names):
449    """
450    Add constraints to the provides and accepts indices based on the attributes
451    of the contstraints.  Also index by name.
452    """
453    for c in constraints:
454        for attr, dict in ((c.provides, provides), (c.accepts, accepts)):
455            for a in attr:
456                if a not in dict: dict[a] = [c]
457                else: dict[a].append(c)
458        if c.name in names: names[c.name].append(c)
459        else: names[c.name]= [ c ]
460
461def get_suffix(fn):
462    """
463    We get filename suffixes a couple places.  It;s worth using the same code.
464    This gets the shortest . separated suffix from a filename, or None.
465    """
466    idx = fn.rfind('.')
467    if idx != -1: return fn[idx+1:]
468    else: return None
469
470
471def output_composition(top, constraints, outfile=None, format=None):
472    """
473    Output the composition to the file named by outfile (if any) in the format
474    given by format if any.  If both are None, output to stdout in topdl
475    """
476    def xml_out(f, top, constraints):
477        """
478        Output into topdl.  Just call the topdl output, as the constraint
479        attributes should be in the topology.
480        """
481        print >>f, "<component>"
482        if constraints:
483            print >>f, "<constraints>"
484            for c in constraints:
485                print >>f, c.to_xml()
486            print >>f, "</constraints>"
487        print >>f, topdl.topology_to_xml(comp, top='experiment')
488        print >>f, "</component>"
489
490    def ns2_out(f, top, constraints):
491        """
492        Reformat the constraint data structures into ns2 constraint comments
493        and output the ns2 using the topdl routine.
494        """
495        # Inner routines
496        # Deal with the possibility that the single string name is still in the
497        # constraint.
498        def name_format(n):
499            if isinstance(n, tuple): return n[0]
500            else: return n
501           
502           
503        # Format the required field back into a string. (ns2_out inner
504        def required_format(x):
505            if x: return "required"
506            else: return "optional"
507       
508        # ns2_out main line
509        for c in constraints:
510            print >>f, "# COMPOSITION: %s:%s:%s:%s" % (
511                    topdl.to_tcl_name(name_format(c.name)),
512                    required_format(c.required), ",".join(c.provides),
513                    ",".join(c.accepts))
514        print >>f, topdl.topology_to_ns2(top)
515
516    # Info to map from format to output routine. 
517    exporters = {
518            'xml':xml_out, 'topdl':xml_out,
519            'tcl': ns2_out, 'ns': ns2_out,
520            }
521
522    if format:
523        # Explicit format set, use it
524        if format in exporters:
525            exporter = exporters[format]
526        else:
527            raise RuntimeError("Unknown format %s" % format)
528    elif outfile:
529        # Determine the format from the suffix (if any)
530        s = get_suffix(outfile)
531        if s and s in exporters:
532            exporter = exporters[s]
533        else:
534            raise RuntimeError("Unknown format (suffix) %s" % outfile)
535    else:
536        # Both outfile and format are empty
537        exporter = xml_out
538
539    # The actual output.  Open the file, if any, and call the exporter
540    if outfile: f = open(outfile, "w")
541    else: f = sys.stdout
542    exporter(f, top, constraints)
543    if outfile: f.close()
544
545def import_components(files):
546    """
547    Pull in the components.  The input is a sequence of tuples where each tuple
548    includes the name of the file to pull the component from and the number of
549    copies to make of it.  The routine to read a file is picked by its suffix.
550    On completion, a tuple containing the number of components successfully
551    read, a list of the topologies, a set of names in use accross all
552    topologies (for inserting later names), the constraints extracted, and
553    indexes mapping the attribute provices and accepted tinto the lists of
554    constraints that provide or accept them is returned.
555    """
556    importers = {
557            'tcl': import_ns2_component, 
558            'ns': import_ns2_component, 
559            'xml':import_xml_component,
560            'topdl':import_xml_component,
561            }
562    names = set()
563    constraints = [ ]
564    provides = { }
565    accepts = { }
566    components = 0
567    topos = [ ]
568
569    for fn, cnt in files:
570        try:
571            s = get_suffix(fn)
572            if s and s in importers:
573                top, cons = importers[s](fn)
574            else:
575                warn("Unknown suffix on file %s.  Ignored" % fn)
576                continue
577        except service_error, e:
578            warn("Remote error on %s: %s" % (fn, e))
579            continue
580        except EnvironmentError, e:
581            warn("Error on %s: %s" % (fn, e))
582            continue
583
584        # Parsed the component and sonctraints, now work through the rest of
585        # the pre-processing.  We do this once per copy of the component
586        # requested, cloning topologies and constraints each time.
587        for i in range(0, cnt):
588            components += 1
589            t = top.clone()
590            c = copy.deepcopy(cons)
591            marks = { }
592            # Bind the constraints in m (the marks copy) to this topology
593            for cc in c:
594                cc.topology = t
595            index_constraints(c, provides, accepts, marks)
596            localize_names(t, names, marks)
597            constraints.extend(c)
598            topos.append(t)
599
600    return (components, topos, names, constraints, provides, accepts)
601
602# Main line begins
603parser = ComposeOptionParser()
604
605opts, args = parser.parse_args()
606
607if opts.cert:
608    cert = opts.cert
609elif os.access(os.path.expanduser("~/.ssl/emulab.pem"), os.R_OK):
610    cert = os.path.expanduser("~/.ssl/emulab.pem")
611else:
612    cert = None
613
614random.seed(opts.seed)
615
616files = opts.files
617files.extend([ (a, 1) for a in args])
618
619# Pull 'em in.
620components, topos, names, constraints, provides, accepts = \
621        import_components(files)
622
623# If more than one component was given, actually do the composition, otherwise
624# this is probably a format conversion.
625if components > 1:
626    # Mix up the constraint indexes
627    randomize_constraint_order((provides, accepts))
628
629    # Now the various components live in the same namespace and are marked with
630    # their composition requirements.
631
632    if not meet_constraints([c for c in constraints if c.required], 
633            provides, accepts, opts.same_node, opts.same_topo, opts.multi_pair):
634        if opts.lax:
635            warn("warning: could not meet all required constraints")
636        else:
637            sys.exit("Failed: could not meet all required constraints")
638
639    meet_constraints([ c for c in constraints if not c.match ], 
640            provides, accepts, opts.same_node, opts.same_topo, opts.multi_pair)
641
642    # Add testbed attributes if requested
643    if opts.add_testbeds:
644        for i, t in enumerate(topos):
645            for e in [ e for e in t.elements if isinstance(e, topdl.Computer)]:
646                e.set_attribute('testbed', 'testbed%03d' % i)
647
648    # Make a topology containing all elements and substrates from components
649    # that had matches.
650    comp = topdl.Topology()
651    for t in set([ c.topology for c in constraints if c.match]):
652        comp.elements.extend([e.clone() for e in t.elements])
653        comp.substrates.extend([s.clone() for s in t.substrates])
654
655    # Add substrates and connections corresponding to composition points
656    connect_composition_points(comp, constraints, names)
657
658elif components == 1:
659    comp = topos[0]
660    if opts.add_testbeds:
661        for e in [ e for e in comp.elements if isinstance(e, topdl.Computer)]:
662            e.set_attribute('testbed', 'testbed001')
663
664else:
665    sys.exit("Did not read any components.")
666
667# Put out the composition with only the unmatched constraints
668output_composition(comp, [c for c in constraints if not c.match], 
669        opts.outfile, opts.format)
670
671sys.exit(0)
Note: See TracBrowser for help on using the repository browser.