source: fedd/compose.py @ 8a0c67f

axis_examplecompt_changesinfo-opsversion-3.01version-3.02
Last change on this file since 8a0c67f was 8a0c67f, checked in by Ted Faber <faber@…>, 14 years ago

comments

  • Property mode set to 100644
File size: 20.8 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
170    @staticmethod
171    def multi_callback(option, opt_str, value, parser):
172        """
173        Parse a --multifile command line option.  The parameter is of the form
174        filename,count.  This splits the argument at the rightmost comma and
175        inserts the filename, count tuple into the "files" option.  It handles
176        a couple error cases, too.
177        """
178        idx = value.rfind(',')
179        if idx != -1:
180            try:
181                parser.values.files.append((value[0:idx], int(value[idx+1:])))
182            except ValueError, e:
183                raise OptionValueError(
184                        "Can't convert %s to int in multifile (%s)" % \
185                                (value[idx+1:], value))
186        else:
187            raise OptionValueError(
188                    "Bad format (need a comma) for multifile: %s" % value)
189
190def warn(msg):
191    """
192    Exactly what you think.  Print a message to stderr
193    """
194    print >>sys.stderr, msg
195
196
197def make_new_name(names, prefix="name"):
198    """
199    Generate an identifier not present in names by appending an integer to
200    prefix.  The new name is inserted into names and returned.
201    """
202    i = 0
203    n = "%s%d" % (prefix,i)
204    while n in names:
205        i += 1
206        n = "%s%d" % (prefix,i)
207    names.add(n)
208    return n
209
210def localize_names(top, names, marks):
211    """
212    Take a topology and rename any substrates or elements that share a name
213    with an existing computer or substrate.  Keep the original name as a
214    localized_name attribute.  In addition, remap any constraints or interfaces
215    that point to the old name over to the new one.  Constraints are found in
216    the marks dict, indexed by node name.  Those constraints name attributes
217    have already been converted to triples (node name, interface name,
218    topology) so only the node name needs to be changed.
219    """
220    sub_map = { }
221    for s in top.substrates:
222        s.set_attribute('localized_name', s.name)
223        if s.name in names:
224            sub_map[s.name] = n = make_new_name(names, "substrate")
225            s.name = n
226        else:
227            names.add(s.name)
228
229    for e in [ e for e in top.elements if isinstance(e, topdl.Computer)]:
230        e.set_attribute('localized_name', e.name)
231        if e.name in names:
232            nn= make_new_name(names, "node")
233            if e.name in marks:
234                marks[e.name].name = nn
235            e.name = nn
236        else:
237            names.add(e.name)
238
239        # Interface mapping.  The list comprehension creates a list of
240        # substrate names where each element in the list is replaced by the
241        # entry in sub_map indexed by it if present and left alone otherwise.
242        for i in e.interface:
243            i.substrate = [ sub_map.get(ii, ii) for ii in i.substrate ]
244
245def meet_constraints(candidates, provides, accepts):
246    """
247    Try to meet all the constraints in candidates using the information in the
248    provides and accepts dicts (which index constraints that provide or accept
249    the given attribute). A constraint is met if it can be matched with another
250    constraint that provides an attribute that the first constraint accepts.
251    Only one match per pair is allowed, and we always prefer matching a
252    required constraint to an unreqired one.  If all the candidates can be
253    matches, return True and return False otherwise.
254    """
255    got_all = True
256    for c in candidates:
257        if not c.match:
258            rmatch = None   # Match to a required constraint
259            umatch = None   # Match to an unrequired constraint
260            for a in c.accepts:
261                for can in provides.get(a,[]):
262                    # A constraint cannot satisfy itself, nor do we allow loops
263                    # within the same topology.  This also excludes matched
264                    # constraints.
265                    if can.name != c.name and can.topology != c.topology and \
266                            not can.match:
267                        # Now check that the candidate also accepts c
268                        for ca in can.accepts:
269                            if ca in c.provides:
270                                if can.required: rmatch = can
271                                else: umatch = can
272                                break
273                       
274                        # Within providers, prefer matches against required
275                        # composition points.
276                        if rmatch:
277                            break
278                # Within acceptance categories, prefer matches against required
279                # composition points
280                if rmatch:
281                    break
282
283            # Move the better match over to the match variable
284            if rmatch: match = rmatch
285            elif umatch: match = umatch
286            else: match = None
287
288            # Done checking all possible matches.  Record the match or note an
289            # unmatched candidate.
290            if match:
291                match.match = c
292                c.match = match
293            else:
294                got_all = False
295    return got_all
296
297def randomize_constraint_order(indexes):
298    """
299    Randomly reorder the lists of constraints that provides and accepts hold.
300    """
301    if not isinstance(indexes, tuple):
302        indexes = (indexes,)
303
304    for idx in indexes:
305        for k in idx.keys():
306            random.shuffle(idx[k])
307
308def remote_ns2topdl(uri, desc, cert):
309    """
310    Call a remote service to convert the ns2 to topdl (and in fact all the way
311    to a topdl.Topology.
312    """
313
314    req = { 'description' : { 'ns2description': desc }, }
315
316    r = service_caller('Ns2Topdl')(uri, req, cert)
317
318    if r.has_key('Ns2TopdlResponseBody'):
319        r = r['Ns2TopdlResponseBody']
320        ed = r.get('experimentdescription', None)
321        if 'topdldescription' in ed:
322            return topdl.Topology(**ed['topdldescription'])
323        else:
324            return None
325    else:
326        return None
327
328def connect_composition_points(top, contraints, names):
329    """
330    top is a topology containing copies of all the topologies represented in
331    the contsraints, flattened into one name space.  This routine inserts the
332    additional substrates required to interconnect the topology as described by
333    the constraints.  After the links are made, unused connection points are
334    purged.
335    """
336    done = set()
337    for c in constraints:
338        if c not in done and c.match:
339            # c is an unprocessed matched constraint.  Create a substrate
340            # and attach it to the interfaces named by c and c.match.
341            sn = make_new_name(names, "sub")
342            s = topdl.Substrate(name=sn)
343
344            # These are the nodes that need to be connected.  Put an new
345            # interface on each one and hook them to the new substrate.
346            for e in [ e for e in top.elements \
347                    if isinstance(e, topdl.Computer) \
348                        and e.name in (c.name, c.match.name)]:
349                ii = make_new_name(set([x.name for x in e.interface]), "inf")
350                e.interface.append(
351                        topdl.Interface(substrate=[sn], name=ii, element=e))
352
353
354            # c and c.match have been processed, so add them to the done set,
355            # and add the substrate
356            top.substrates.append(s)
357            done.add(c)
358            done.add(c.match)
359
360    top.incorporate_elements()
361
362def import_ns2_constraints(contents):
363    """
364    Contents is a list containing the lines of an annotated ns2 file.  This
365    routine extracts the constraint comment lines and convertes them into
366    constraints in the namespace of the tcl experiment, as well as inserting
367    them in the accepts and provides indices.
368
369    Constraints are given in lines of the form
370        name:required:provides:accepts
371    where name is the name of the node in the current topology, if the second
372    field is "required" this is a required constraint, a list of attributes
373    that this connection point provides and a list that it accepts.  The
374    provides and accepts lists are comma-separated.  The constraint is added to
375    the marks dict under the name key and that dict is returned.
376    """
377
378    const_re = re.compile("\s*#\s*COMPOSITION:\s*([^:]+:[^:]+:.*)")
379
380    marks = { }
381    for l in contents:
382        m = const_re.search(l)
383        if m:
384            exp = re.sub('\s', '', m.group(1))
385            nn, r, p, a = exp.split(":")
386            if nn.find('(') != -1:
387                # Convert array references into topdl names
388                nn = re.sub('\\((\\d)\\)', '-\\1', nn)
389            p = p.split(",")
390            a = a.split(",")
391            c = constraint(name=nn, required=(r == 'required'),
392                    provides=p, accepts=a)
393            marks[nn] = c
394    return marks
395
396def import_ns2_component(fn):
397    """
398    Pull a composition component in from an ns2 description.  The Constraints
399    are parsed from the comments using import_ns2_constraints and the topology
400    is created using a call to a fedd exporting the Ns2Topdl function.  A
401    topdl.Topology object rempresenting the component's topology and a dict
402    mapping constraints from the components names to the conttraints is
403    returned.  If either the file read or the conversion fails, appropriate
404    Exceptions are thrown.
405    """
406    f = open(fn, "r")
407    contents = [ l for l in f ]
408
409    marks = import_ns2_constraints(contents)
410    top = remote_ns2topdl(opts.url, "".join(contents), cert)
411    if not top:
412        raise RuntimeError("Cannot create topology from: %s" % fn)
413
414    return (top, marks)
415
416def import_topdl_component(fn):
417    """
418    Pull a component in from a topdl description.  The topology generation is
419    straightforward and the constraints are pulled from the attributes of
420    Computer nodes in the experiment.  The compisition_point attribute being
421    true marks a node as a composition point.  The required, provides and
422    accepts attributes map directly into those constraint fields.  The dict of
423    constraints and the topology are returned.
424    """
425    top = topdl.topology_from_xml(filename=fn, top='experiment')
426    cons = constraints_from_xml(filename=fn, top='constraints')
427    marks= dict([(c.name, c ) for c in cons])
428    return (top, marks)
429
430def index_constraints(constraints, provides, accepts):
431    """
432    Add constraints to the provides and accepts indices based on the attributes
433    of the contstraints.
434    """
435    for c in constraints:
436        for attr, dict in ((c.provides, provides), (c.accepts, accepts)):
437            for a in attr:
438                if a not in dict: dict[a] = [c]
439                else: dict[a].append(c)
440
441def get_suffix(fn):
442    """
443    We get filename suffixes a couple places.  It;s worth using the same code.
444    This gets the shortest . separated suffix from a filename, or None.
445    """
446    idx = fn.rfind('.')
447    if idx != -1: return fn[idx+1:]
448    else: return None
449
450
451def output_composition(top, constraints, outfile=None, format=None):
452    """
453    Output the composition to the file named by outfile (if any) in the format
454    given by format if any.  If both are None, output to stdout in topdl
455    """
456    def topdl_out(f, top, constraints):
457        """
458        Output into topdl.  Just call the topdl output, as the constraint
459        attributes should be in the topology.
460        """
461        print >>f, "<component>"
462        if constraints:
463            print >>f, "<constraints>"
464            for c in constraints:
465                print >>f, c.to_xml()
466            print >>f, "</constraints>"
467        print >>f, topdl.topology_to_xml(comp, top='experiment')
468        print >>f, "</component>"
469
470    def ns2_out(f, top, constraints):
471        """
472        Reformat the constraint data structures into ns2 constraint comments
473        and output the ns2 using the topdl routine.
474        """
475        # Inner routines
476        # Deal with the possibility that the single string name is still in the
477        # constraint.
478        def name_format(n):
479            if isinstance(n, tuple): return n[0]
480            else: return n
481           
482           
483        # Format the required field back into a string. (ns2_out inner
484        def required_format(x):
485            if x: return "required"
486            else: return "optional"
487       
488        # ns2_out main line
489        for c in constraints:
490            print >>f, "# COMPOSITION: %s:%s:%s:%s" % (
491                    topdl.to_tcl_name(name_format(c.name)),
492                    required_format(c.required), ",".join(c.provides),
493                    ",".join(c.accepts))
494        print >>f, topdl.topology_to_ns2(top)
495
496    # Info to map from format to output routine. 
497    exporters = {
498            'xml':topdl_out, 'topdl':topdl_out,
499            'tcl': ns2_out, 'ns': ns2_out,
500            }
501
502    if format:
503        # Explicit format set, use it
504        if format in exporters:
505            exporter = exporters[format]
506        else:
507            raise RuntimeError("Unknown format %s" % format)
508    elif outfile:
509        # Determine the format from the suffix (if any)
510        s = get_suffix(outfile)
511        if s and s in exporters:
512            exporter = exporters[s]
513        else:
514            raise RuntimeError("Unknown format (suffix) %s" % outfile)
515    else:
516        # Both outfile and format are empty
517        exporter = topdl_out
518
519    # The actual output.  Open the file, if any, and call the exporter
520    if outfile: f = open(outfile, "w")
521    else: f = sys.stdout
522    exporter(f, top, constraints)
523    if outfile: f.close()
524
525def import_components(files):
526    """
527    Pull in the components.  The input is a sequence of tuples where each tuple
528    includes the name of the file to pull the component from and the number of
529    copies to make of it.  The routine to read a file is picked by its suffix.
530    On completion, a tuple containing the number of components successfully
531    read, a list of the topologies, a set of names in use accross all
532    topologies (for inserting later names), the constraints extracted, and
533    indexes mapping the attribute provices and accepted tinto the lists of
534    constraints that provide or accept them is returned.
535    """
536    importers = {
537            'tcl': import_ns2_component, 
538            'ns': import_ns2_component, 
539            'xml':import_topdl_component,
540            'topdl':import_topdl_component,
541            }
542    names = set()
543    constraints = [ ]
544    provides = { }
545    accepts = { }
546    components = 0
547    topos = [ ]
548
549    for fn, cnt in files:
550        top = None
551        try:
552            s = get_suffix(fn)
553            if s and s in importers:
554                top, marks = importers[s](fn)
555            else:
556                warn("Unknown suffix on file %s.  Ignored" % fn)
557                continue
558        except service_error, e:
559            warn("Remote error on %s: %s" % (fn, e))
560            continue
561        except EnvironmentError, e:
562            warn("Error on %s: %s" % (fn, e))
563            continue
564
565        # Parsed the component and sonctraints, now work through the rest of
566        # the pre-processing.  We do this once per copy of the component
567        # requested, cloning topologies and constraints each time.
568        for i in range(0, cnt):
569            components += 1
570            t = top.clone()
571            m = copy.deepcopy(marks)
572            # Bind the constraints in m (the marks copy) to this topology
573            for c in m.values():
574                c.topology = t
575            index_constraints(m.values(), provides, accepts)
576            localize_names(t, names, m)
577            constraints.extend(m.values())
578            topos.append(t)
579
580    return (components, topos, names, constraints, provides, accepts)
581
582# Main line begins
583parser = ComposeOptionParser()
584
585opts, args = parser.parse_args()
586
587if opts.cert:
588    cert = opts.cert
589elif os.access(os.path.expanduser("~/.ssl/emulab.pem"), os.R_OK):
590    cert = os.path.expanduser("~/.ssl/emulab.pem")
591else:
592    cert = None
593
594random.seed(opts.seed)
595
596files = opts.files
597files.extend([ (a, 1) for a in args])
598
599# Pull 'em in.
600components, topos, names, constraints, provides, accepts = \
601        import_components(files)
602
603# If more than one component was given, actually do the composition, otherwise
604# this is probably a format conversion.
605if components > 1:
606    # Mix up the constraint indexes
607    randomize_constraint_order((provides, accepts))
608
609    # Now the various components live in the same namespace and are marked with
610    # their composition requirements.
611
612    if not meet_constraints([c for c in constraints if c.required], 
613            provides, accepts):
614        if opts.lax:
615            warn("warning: could not meet all required constraints")
616        else:
617            sys.exit("Failed: could not meet all required constraints")
618
619    meet_constraints([ c for c in constraints if not c.match ], 
620            provides, accepts)
621
622    # Add testbed attributes if requested
623    if opts.add_testbeds:
624        for i, t in enumerate(topos):
625            for e in [ e for e in t.elements if isinstance(e, topdl.Computer)]:
626                e.set_attribute('testbed', 'testbed%03d' % i)
627
628    # Make a topology containing all elements and substrates from components
629    # that had matches.
630    comp = topdl.Topology()
631    for t in set([ c.topology for c in constraints if c.match]):
632        comp.elements.extend([e.clone() for e in t.elements])
633        comp.substrates.extend([s.clone() for s in t.substrates])
634
635    # Add substrates and connections corresponding to composition points
636    connect_composition_points(comp, constraints, names)
637
638elif components == 1:
639    comp = topos[0]
640    if opts.add_testbeds:
641        for e in [ e for e in comp.elements if isinstance(e, topdl.Computer)]:
642            e.set_attribute('testbed', 'testbed001')
643
644else:
645    sys.exit("Did not read any components.")
646
647# Put out the composition with only the unmatched constraints
648output_composition(comp, [c for c in constraints if not c.match], 
649        opts.outfile, opts.format)
Note: See TracBrowser for help on using the repository browser.