source: fedd/compose.py @ 7ee16b3

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

Allow nodes to meet multiple constraints.

  • Property mode set to 100644
File size: 20.9 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            for c in marks.get(e.name, []):
234                c.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    node_match = { }
257    for c in candidates:
258        if not c.match:
259            rmatch = None   # Match to a required constraint
260            umatch = None   # Match to an unrequired constraint
261            for a in c.accepts:
262                for can in provides.get(a,[]):
263                    # A constraint cannot satisfy itself, nor do we allow loops
264                    # within the same topology.  This also excludes matched
265                    # constraints.
266                    if can.name != c.name and can.topology != c.topology and \
267                            not can.match:
268                        # Don't allow multiple matches between the same nodes
269                        # (the "if" above is already pretty crowded).
270                        if c.name in node_match and \
271                                can.name in node_match[c.name]:
272                            continue
273                        # Now check that can also accepts c
274                        for ca in can.accepts:
275                            if ca in c.provides:
276                                if can.required: rmatch = can
277                                else: umatch = can
278                                break
279                       
280                        # Within providers, prefer matches against required
281                        # composition points.
282                        if rmatch:
283                            break
284                # Within acceptance categories, prefer matches against required
285                # composition points
286                if rmatch:
287                    break
288
289            # Move the better match over to the match variable
290            if rmatch: match = rmatch
291            elif umatch: match = umatch
292            else: match = None
293
294            # Done checking all possible matches.  Record the match or note an
295            # unmatched candidate.
296            if match:
297                match.match = c
298                c.match = match
299                # Note the match of the nodes
300                for a, b in ((c.name, match.name), (match.name, c.name)):
301                    if a in node_match: node_match[a].append(b)
302                    else: node_match[a] = [ b]
303            else:
304                got_all = False
305    return got_all
306
307def randomize_constraint_order(indexes):
308    """
309    Randomly reorder the lists of constraints that provides and accepts hold.
310    """
311    if not isinstance(indexes, tuple):
312        indexes = (indexes,)
313
314    for idx in indexes:
315        for k in idx.keys():
316            random.shuffle(idx[k])
317
318def remote_ns2topdl(uri, desc, cert):
319    """
320    Call a remote service to convert the ns2 to topdl (and in fact all the way
321    to a topdl.Topology.
322    """
323
324    req = { 'description' : { 'ns2description': desc }, }
325
326    r = service_caller('Ns2Topdl')(uri, req, cert)
327
328    if r.has_key('Ns2TopdlResponseBody'):
329        r = r['Ns2TopdlResponseBody']
330        ed = r.get('experimentdescription', None)
331        if 'topdldescription' in ed:
332            return topdl.Topology(**ed['topdldescription'])
333        else:
334            return None
335    else:
336        return None
337
338def connect_composition_points(top, contraints, names):
339    """
340    top is a topology containing copies of all the topologies represented in
341    the contsraints, flattened into one name space.  This routine inserts the
342    additional substrates required to interconnect the topology as described by
343    the constraints.  After the links are made, unused connection points are
344    purged.
345    """
346    done = set()
347    for c in constraints:
348        if c not in done and c.match:
349            # c is an unprocessed matched constraint.  Create a substrate
350            # and attach it to the interfaces named by c and c.match.
351            sn = make_new_name(names, "sub")
352            s = topdl.Substrate(name=sn)
353
354            # These are the nodes that need to be connected.  Put an new
355            # interface on each one and hook them to the new substrate.
356            for e in [ e for e in top.elements \
357                    if isinstance(e, topdl.Computer) \
358                        and e.name in (c.name, c.match.name)]:
359                ii = make_new_name(set([x.name for x in e.interface]), "inf")
360                e.interface.append(
361                        topdl.Interface(substrate=[sn], name=ii, element=e))
362
363
364            # c and c.match have been processed, so add them to the done set,
365            # and add the substrate
366            top.substrates.append(s)
367            done.add(c)
368            done.add(c.match)
369
370    top.incorporate_elements()
371
372def import_ns2_constraints(contents):
373    """
374    Contents is a list containing the lines of an annotated ns2 file.  This
375    routine extracts the constraint comment lines and convertes them into
376    constraints in the namespace of the tcl experiment, as well as inserting
377    them in the accepts and provides indices.
378
379    Constraints are given in lines of the form
380        name:required:provides:accepts
381    where name is the name of the node in the current topology, if the second
382    field is "required" this is a required constraint, a list of attributes
383    that this connection point provides and a list that it accepts.  The
384    provides and accepts lists are comma-separated.  The constraint is added to
385    the marks dict under the name key and that dict is returned.
386    """
387
388    const_re = re.compile("\s*#\s*COMPOSITION:\s*([^:]+:[^:]+:.*)")
389
390    constraints = [ ]
391    for l in contents:
392        m = const_re.search(l)
393        if m:
394            exp = re.sub('\s', '', m.group(1))
395            nn, r, p, a = exp.split(":")
396            if nn.find('(') != -1:
397                # Convert array references into topdl names
398                nn = re.sub('\\((\\d)\\)', '-\\1', nn)
399            p = p.split(",")
400            a = a.split(",")
401            constraints.append(constraint(name=nn, required=(r == 'required'),
402                    provides=p, accepts=a))
403    return constraints
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.
428    """
429    return (topdl.topology_from_xml(filename=fn, top='experiment'), 
430            constraints_from_xml(filename=fn, top='constraints'))
431
432def index_constraints(constraints, provides, accepts, names):
433    """
434    Add constraints to the provides and accepts indices based on the attributes
435    of the contstraints.  Also index by name.
436    """
437    for c in constraints:
438        for attr, dict in ((c.provides, provides), (c.accepts, accepts)):
439            for a in attr:
440                if a not in dict: dict[a] = [c]
441                else: dict[a].append(c)
442        if c.name in names: names[c.name].append(c)
443        else: names[c.name]= [ c ]
444
445def get_suffix(fn):
446    """
447    We get filename suffixes a couple places.  It;s worth using the same code.
448    This gets the shortest . separated suffix from a filename, or None.
449    """
450    idx = fn.rfind('.')
451    if idx != -1: return fn[idx+1:]
452    else: return None
453
454
455def output_composition(top, constraints, outfile=None, format=None):
456    """
457    Output the composition to the file named by outfile (if any) in the format
458    given by format if any.  If both are None, output to stdout in topdl
459    """
460    def topdl_out(f, top, constraints):
461        """
462        Output into topdl.  Just call the topdl output, as the constraint
463        attributes should be in the topology.
464        """
465        print >>f, "<component>"
466        if constraints:
467            print >>f, "<constraints>"
468            for c in constraints:
469                print >>f, c.to_xml()
470            print >>f, "</constraints>"
471        print >>f, topdl.topology_to_xml(comp, top='experiment')
472        print >>f, "</component>"
473
474    def ns2_out(f, top, constraints):
475        """
476        Reformat the constraint data structures into ns2 constraint comments
477        and output the ns2 using the topdl routine.
478        """
479        # Inner routines
480        # Deal with the possibility that the single string name is still in the
481        # constraint.
482        def name_format(n):
483            if isinstance(n, tuple): return n[0]
484            else: return n
485           
486           
487        # Format the required field back into a string. (ns2_out inner
488        def required_format(x):
489            if x: return "required"
490            else: return "optional"
491       
492        # ns2_out main line
493        for c in constraints:
494            print >>f, "# COMPOSITION: %s:%s:%s:%s" % (
495                    topdl.to_tcl_name(name_format(c.name)),
496                    required_format(c.required), ",".join(c.provides),
497                    ",".join(c.accepts))
498        print >>f, topdl.topology_to_ns2(top)
499
500    # Info to map from format to output routine. 
501    exporters = {
502            'xml':topdl_out, 'topdl':topdl_out,
503            'tcl': ns2_out, 'ns': ns2_out,
504            }
505
506    if format:
507        # Explicit format set, use it
508        if format in exporters:
509            exporter = exporters[format]
510        else:
511            raise RuntimeError("Unknown format %s" % format)
512    elif outfile:
513        # Determine the format from the suffix (if any)
514        s = get_suffix(outfile)
515        if s and s in exporters:
516            exporter = exporters[s]
517        else:
518            raise RuntimeError("Unknown format (suffix) %s" % outfile)
519    else:
520        # Both outfile and format are empty
521        exporter = topdl_out
522
523    # The actual output.  Open the file, if any, and call the exporter
524    if outfile: f = open(outfile, "w")
525    else: f = sys.stdout
526    exporter(f, top, constraints)
527    if outfile: f.close()
528
529def import_components(files):
530    """
531    Pull in the components.  The input is a sequence of tuples where each tuple
532    includes the name of the file to pull the component from and the number of
533    copies to make of it.  The routine to read a file is picked by its suffix.
534    On completion, a tuple containing the number of components successfully
535    read, a list of the topologies, a set of names in use accross all
536    topologies (for inserting later names), the constraints extracted, and
537    indexes mapping the attribute provices and accepted tinto the lists of
538    constraints that provide or accept them is returned.
539    """
540    importers = {
541            'tcl': import_ns2_component, 
542            'ns': import_ns2_component, 
543            'xml':import_topdl_component,
544            'topdl':import_topdl_component,
545            }
546    names = set()
547    constraints = [ ]
548    provides = { }
549    accepts = { }
550    components = 0
551    topos = [ ]
552
553    for fn, cnt in files:
554        try:
555            s = get_suffix(fn)
556            if s and s in importers:
557                top, cons = importers[s](fn)
558            else:
559                warn("Unknown suffix on file %s.  Ignored" % fn)
560                continue
561        except service_error, e:
562            warn("Remote error on %s: %s" % (fn, e))
563            continue
564        except EnvironmentError, e:
565            warn("Error on %s: %s" % (fn, e))
566            continue
567
568        # Parsed the component and sonctraints, now work through the rest of
569        # the pre-processing.  We do this once per copy of the component
570        # requested, cloning topologies and constraints each time.
571        for i in range(0, cnt):
572            components += 1
573            t = top.clone()
574            c = copy.deepcopy(cons)
575            marks = { }
576            # Bind the constraints in m (the marks copy) to this topology
577            for cc in c:
578                cc.topology = t
579            index_constraints(c, provides, accepts, marks)
580            localize_names(t, names, marks)
581            constraints.extend(c)
582            topos.append(t)
583
584    return (components, topos, names, constraints, provides, accepts)
585
586# Main line begins
587parser = ComposeOptionParser()
588
589opts, args = parser.parse_args()
590
591if opts.cert:
592    cert = opts.cert
593elif os.access(os.path.expanduser("~/.ssl/emulab.pem"), os.R_OK):
594    cert = os.path.expanduser("~/.ssl/emulab.pem")
595else:
596    cert = None
597
598random.seed(opts.seed)
599
600files = opts.files
601files.extend([ (a, 1) for a in args])
602
603# Pull 'em in.
604components, topos, names, constraints, provides, accepts = \
605        import_components(files)
606
607# If more than one component was given, actually do the composition, otherwise
608# this is probably a format conversion.
609if components > 1:
610    # Mix up the constraint indexes
611    randomize_constraint_order((provides, accepts))
612
613    # Now the various components live in the same namespace and are marked with
614    # their composition requirements.
615
616    if not meet_constraints([c for c in constraints if c.required], 
617            provides, accepts):
618        if opts.lax:
619            warn("warning: could not meet all required constraints")
620        else:
621            sys.exit("Failed: could not meet all required constraints")
622
623    meet_constraints([ c for c in constraints if not c.match ], 
624            provides, accepts)
625
626    # Add testbed attributes if requested
627    if opts.add_testbeds:
628        for i, t in enumerate(topos):
629            for e in [ e for e in t.elements if isinstance(e, topdl.Computer)]:
630                e.set_attribute('testbed', 'testbed%03d' % i)
631
632    # Make a topology containing all elements and substrates from components
633    # that had matches.
634    comp = topdl.Topology()
635    for t in set([ c.topology for c in constraints if c.match]):
636        comp.elements.extend([e.clone() for e in t.elements])
637        comp.substrates.extend([s.clone() for s in t.substrates])
638
639    # Add substrates and connections corresponding to composition points
640    connect_composition_points(comp, constraints, names)
641
642elif components == 1:
643    comp = topos[0]
644    if opts.add_testbeds:
645        for e in [ e for e in comp.elements if isinstance(e, topdl.Computer)]:
646            e.set_attribute('testbed', 'testbed001')
647
648else:
649    sys.exit("Did not read any components.")
650
651# Put out the composition with only the unmatched constraints
652output_composition(comp, [c for c in constraints if not c.match], 
653        opts.outfile, opts.format)
Note: See TracBrowser for help on using the repository browser.