source: fedd/compose.py @ 4d4dde4

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

Constraints come out of a component file now, and the clunky composition attributes are gone.

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