source: fedd/federation/emulab_access.py @ 4d68ba6

compt_changes
Last change on this file since 4d68ba6 was 4d68ba6, checked in by Ted Faber <faber@…>, 12 years ago

Do not add a startcommand to non-federated experiments

  • Property mode set to 100644
File size: 37.7 KB
RevLine 
[19cc408]1#!/usr/local/bin/python
2
3import os,sys
[eeb0088]4import stat # for chmod constants
[19cc408]5import re
[ab847bc]6import random
[19cc408]7import string
8import copy
[d81971a]9import pickle
[c971895]10import logging
[eeb0088]11import subprocess
[06cc65b]12import traceback
[19cc408]13
[f8582c9]14from threading import *
[8e6fe4d]15from M2Crypto.SSL import SSLError
[f8582c9]16
[f771e2f]17from access import access_base
18
[ec4fb42]19from util import *
[6bedbdba]20from deter import fedid, generate_fedid
[6e63513]21from authorizer import authorizer, abac_authorizer
[6a0c9f4]22from service_error import service_error
[9460b1e]23from remote_service import xmlrpc_handler, soap_handler, service_caller
[e83f2f2]24from proof import proof as access_proof
[11a08b0]25
[6c57fe9]26import httplib
27import tempfile
28from urlparse import urlparse
29
[6bedbdba]30from deter import topdl
[11860f52]31import list_log
[06c1dba]32import emulab_segment
[11860f52]33
[0ea11af]34
35# Make log messages disappear if noone configures a fedd logger
[11a08b0]36class nullHandler(logging.Handler):
37    def emit(self, record): pass
38
39fl = logging.getLogger("fedd.access")
40fl.addHandler(nullHandler())
[19cc408]41
[ee950c2]42class access(access_base):
[19cc408]43    """
44    The implementation of access control based on mapping users to projects.
45
46    Users can be mapped to existing projects or have projects created
47    dynamically.  This implements both direct requests and proxies.
48    """
49
[53b5c18]50    max_name_len = 19
51
[3f6bc5f]52    def __init__(self, config=None, auth=None):
[866c983]53        """
54        Initializer.  Pulls parameters out of the ConfigParser's access section.
55        """
56
[f771e2f]57        access_base.__init__(self, config, auth)
[866c983]58
[53b5c18]59        self.max_name_len = access.max_name_len
60
[866c983]61        self.allow_proxy = config.getboolean("access", "allow_proxy")
62
63        self.domain = config.get("access", "domain")
[eeb0088]64        self.userconfdir = config.get("access","userconfdir")
65        self.userconfcmd = config.get("access","userconfcmd")
[fe28bb2]66        self.userconfurl = config.get("access","userconfurl")
[9b3627e]67        self.federation_software = config.get("access", "federation_software")
68        self.portal_software = config.get("access", "portal_software")
[e76f38a]69        self.local_seer_software = config.get("access", "local_seer_software")
70        self.local_seer_image = config.get("access", "local_seer_image")
71        self.local_seer_start = config.get("access", "local_seer_start")
[49051fb]72        self.seer_master_start = config.get("access", "seer_master_start")
[ecca6eb]73        self.ssh_privkey_file = config.get("access","ssh_privkey_file")
[3bddd24]74        self.ssh_pubkey_file = config.get("access","ssh_pubkey_file")
[6280b1f]75        self.ssh_port = config.get("access","ssh_port") or "22"
[181aeb4]76        self.boss = config.get("access", "boss")
[814b5e5]77        self.ops = config.get("access", "ops")
[181aeb4]78        self.xmlrpc_cert = config.get("access", "xmlrpc_cert")
79        self.xmlrpc_certpw = config.get("access", "xmlrpc_certpw")
[5e1fb7b]80
81        self.dragon_endpoint = config.get("access", "dragon")
82        self.dragon_vlans = config.get("access", "dragon_vlans")
83        self.deter_internal = config.get("access", "deter_internal")
84
85        self.tunnel_config = config.getboolean("access", "tunnel_config")
86        self.portal_command = config.get("access", "portal_command")
87        self.portal_image = config.get("access", "portal_image")
88        self.portal_type = config.get("access", "portal_type") or "pc"
89        self.portal_startcommand = config.get("access", "portal_startcommand")
90        self.node_startcommand = config.get("access", "node_startcommand")
91
[f771e2f]92        self.federation_software = self.software_list(self.federation_software)
93        self.portal_software = self.software_list(self.portal_software)
94        self.local_seer_software = self.software_list(self.local_seer_software)
[11860f52]95
96        self.access_type = self.access_type.lower()
[06c1dba]97        self.start_segment = emulab_segment.start_segment
98        self.stop_segment = emulab_segment.stop_segment
99        self.info_segment = emulab_segment.info_segment
100        self.operation_segment = emulab_segment.operation_segment
[866c983]101
102        self.restricted = [ ]
103        tb = config.get('access', 'testbed')
104        if tb: self.testbed = [ t.strip() for t in tb.split(',') ]
105        else: self.testbed = [ ]
106
[6e63513]107        # authorization information
108        self.auth_type = config.get('access', 'auth_type') \
[ee950c2]109                or 'abac'
[6e63513]110        self.auth_dir = config.get('access', 'auth_dir')
111        accessdb = config.get("access", "accessdb")
112        # initialize the authorization system
[ee950c2]113        if self.auth_type == 'abac':
[6e63513]114            self.auth = abac_authorizer(load=self.auth_dir)
[c002cb2]115            self.access = [ ]
[6e63513]116            if accessdb:
[78f2668]117                self.read_access(accessdb, self.access_tuple)
[6e63513]118        else:
119            raise service_error(service_error.internal, 
120                    "Unknown auth_type: %s" % self.auth_type)
[f771e2f]121
[06cc65b]122        # read_state in the base_class
[f771e2f]123        self.state_lock.acquire()
[ee950c2]124        if 'allocation' not in self.state: self.state['allocation']= { }
[f771e2f]125        self.allocation = self.state['allocation']
126        self.state_lock.release()
[a20a20f]127        self.exports = {
128                'SMB': self.export_SMB,
129                'seer': self.export_seer,
130                'tmcd': self.export_tmcd,
131                'userconfig': self.export_userconfig,
132                'project_export': self.export_project_export,
133                'local_seer_control': self.export_local_seer,
134                'seer_master': self.export_seer_master,
135                'hide_hosts': self.export_hide_hosts,
136                }
137
138        if not self.local_seer_image or not self.local_seer_software or \
139                not self.local_seer_start:
140            if 'local_seer_control' in self.exports:
141                del self.exports['local_seer_control']
142
143        if not self.local_seer_image or not self.local_seer_software or \
144                not self.seer_master_start:
145            if 'seer_master' in self.exports:
146                del self.exports['seer_master']
[866c983]147
148
149        self.soap_services = {\
150            'RequestAccess': soap_handler("RequestAccess", self.RequestAccess),
151            'ReleaseAccess': soap_handler("ReleaseAccess", self.ReleaseAccess),
[cc8d8e9]152            'StartSegment': soap_handler("StartSegment", self.StartSegment),
[e76f38a]153            'TerminateSegment': soap_handler("TerminateSegment",
154                self.TerminateSegment),
[6e33086]155            'InfoSegment': soap_handler("InfoSegment", self.InfoSegment),
[b709861]156            'OperationSegment': soap_handler("OperationSegment",
157                self.OperationSegment),
[866c983]158            }
159        self.xmlrpc_services =  {\
160            'RequestAccess': xmlrpc_handler('RequestAccess',
161                self.RequestAccess),
162            'ReleaseAccess': xmlrpc_handler('ReleaseAccess',
163                self.ReleaseAccess),
[5ae3857]164            'StartSegment': xmlrpc_handler("StartSegment", self.StartSegment),
165            'TerminateSegment': xmlrpc_handler('TerminateSegment',
166                self.TerminateSegment),
[6e33086]167            'InfoSegment': xmlrpc_handler("InfoSegment", self.InfoSegment),
[b709861]168            'OperationSegment': xmlrpc_handler("OperationSegment",
169                self.OperationSegment),
[866c983]170            }
171
[2761484]172        self.call_SetValue = service_caller('SetValue')
[2ee4226]173        self.call_GetValue = service_caller('GetValue', log=self.log)
[866c983]174
[6e63513]175    @staticmethod
[78f2668]176    def access_tuple(str):
[6e63513]177        """
[f77a256]178        Convert a string of the form (id, id, id) into an access_project.  This
179        is called by read_access to convert to local attributes.  It returns a
180        tuple of the form (project, user, certificate_file).
[6e63513]181        """
182
183        str = str.strip()
[f77a256]184        if str.startswith('(') and str.endswith(')') and str.count(',') == 2:
[725c55d]185            # The slice takes the parens off the string.
[f77a256]186            proj, user, cert = str[1:-1].split(',')
187            return (proj.strip(), user.strip(), cert.strip())
[6e63513]188        else:
189            raise self.parse_error(
[f77a256]190                    'Bad mapping (unbalanced parens or more than 2 commas)')
[6e63513]191
[06cc65b]192    # RequestAccess support routines
193
[f77a256]194    def save_project_state(self, aid, pname, uname, certf, owners):
[f771e2f]195        """
[ee950c2]196        Save the project, user, and owners associated with this allocation.
197        This creates the allocation entry.
[f771e2f]198        """
199        self.state_lock.acquire()
200        self.allocation[aid] = { }
[ee950c2]201        self.allocation[aid]['project'] = pname
202        self.allocation[aid]['user'] = uname
[f77a256]203        self.allocation[aid]['cert'] = certf
[f771e2f]204        self.allocation[aid]['owners'] = owners
205        self.write_state()
206        self.state_lock.release()
207        return (pname, uname)
[19cc408]208
[06cc65b]209    # End of RequestAccess support routines
210
[19cc408]211    def RequestAccess(self, req, fid):
[866c983]212        """
213        Handle the access request.  Proxy if not for us.
214
215        Parse out the fields and make the allocations or rejections if for us,
216        otherwise, assuming we're willing to proxy, proxy the request out.
217        """
218
219        def gateway_hardware(h):
[5e1fb7b]220            if h == 'GWTYPE': return self.portal_type or 'GWTYPE'
[866c983]221            else: return h
222
[43197eb]223        def get_export_project(svcs):
224            """
225            if the service requests includes one to export a project, return
226            that project.
227            """
228            rv = None
229            for s in svcs:
230                if s.get('name', '') == 'project_export' and \
231                        s.get('visibility', '') == 'export':
232                    if not rv: 
[1f6a573]233                        for a in s.get('fedAttr', []):
[43197eb]234                            if a.get('attribute', '') == 'project' \
235                                    and 'value' in a:
236                                rv = a['value']
237                    else:
238                        raise service_error(service_error, access, 
239                                'Requesting multiple project exports is ' + \
240                                        'not supported');
241            return rv
242
[8cab4c2]243        self.log.info("RequestAccess called by %s" % fid)
[866c983]244        # The dance to get into the request body
245        if req.has_key('RequestAccessRequestBody'):
246            req = req['RequestAccessRequestBody']
247        else:
248            raise service_error(service_error.req, "No request!?")
249
[1f6a573]250        # if this includes a project export request, construct a filter such
251        # that only the ABAC attributes mapped to that project are checked for
252        # access.
253        if 'service' in req:
254            ep = get_export_project(req['service'])
[de86b35]255            if ep: pf = lambda(a): a.value[0] == ep
256            else: pf = None
[1f6a573]257        else:
258            ep = None
259            pf = None
260
[c573278]261        if self.auth.import_credentials(
262                data_list=req.get('abac_credential', [])):
263            self.auth.save()
[cde9b98]264        else:
265            self.log.debug('failed to import incoming credentials')
[866c983]266
[ee950c2]267        if self.auth_type == 'abac':
268            found, owners, proof = self.lookup_access(req, fid, filter=pf)
[6e63513]269        else:
270            raise service_error(service_error.internal, 
271                    'Unknown auth_type: %s' % self.auth_type)
[f771e2f]272        ap = None
[866c983]273
[f771e2f]274        # keep track of what's been added
275        allocID, alloc_cert = generate_fedid(subj="alloc", log=self.log)
276        aid = unicode(allocID)
277
[f77a256]278        pname, uname = self.save_project_state(aid, found[0], found[1], 
279                found[2], owners)
[f771e2f]280
281        services, svc_state = self.export_services(req.get('service',[]),
282                pname, uname)
283        self.state_lock.acquire()
284        # Store services state in global state
285        for k, v in svc_state.items():
286            self.allocation[aid][k] = v
[c65b7e4]287        self.append_allocation_authorization(aid, 
288                set([(o, allocID) for o in owners]), state_attr='allocation')
[f771e2f]289        self.write_state()
290        self.state_lock.release()
291        try:
292            f = open("%s/%s.pem" % (self.certdir, aid), "w")
293            print >>f, alloc_cert
294            f.close()
295        except EnvironmentError, e:
[8cab4c2]296            self.log.info("RequestAccess failed for by %s: internal error" \
297                    % fid)
[f771e2f]298            raise service_error(service_error.internal, 
299                    "Can't open %s/%s : %s" % (self.certdir, aid, e))
[8cab4c2]300        self.log.debug('RequestAccess Returning allocation ID: %s' % allocID)
[f771e2f]301        resp = self.build_access_response({ 'fedid': allocID } ,
[ee950c2]302                pname, services, proof)
[f771e2f]303        return resp
[d81971a]304
305    def ReleaseAccess(self, req, fid):
[8cab4c2]306        self.log.info("ReleaseAccess called by %s" % fid)
[866c983]307        # The dance to get into the request body
308        if req.has_key('ReleaseAccessRequestBody'):
309            req = req['ReleaseAccessRequestBody']
310        else:
311            raise service_error(service_error.req, "No request!?")
312
[8cf2b90e]313        try:
314            if req['allocID'].has_key('localname'):
315                auth_attr = aid = req['allocID']['localname']
316            elif req['allocID'].has_key('fedid'):
317                aid = unicode(req['allocID']['fedid'])
318                auth_attr = req['allocID']['fedid']
319            else:
320                raise service_error(service_error.req,
321                        "Only localnames and fedids are understood")
322        except KeyError:
323            raise service_error(service_error.req, "Badly formed request")
324
[725c55d]325        self.log.debug("[access] deallocation requested for %s by %s" % \
326                (aid, fid))
[e83f2f2]327        access_ok, proof = self.auth.check_attribute(fid, auth_attr, 
328                with_proof=True)
329        if not access_ok:
[8cf2b90e]330            self.log.debug("[access] deallocation denied for %s", aid)
331            raise service_error(service_error.access, "Access Denied")
332
333        self.state_lock.acquire()
334        if aid in self.allocation:
335            self.log.debug("Found allocation for %s" %aid)
[c65b7e4]336            self.clear_allocation_authorization(aid, state_attr='allocation')
[8cf2b90e]337            del self.allocation[aid]
338            self.write_state()
339            self.state_lock.release()
[ee950c2]340            # Remove the access cert
[8cf2b90e]341            cf = "%s/%s.pem" % (self.certdir, aid)
342            self.log.debug("Removing %s" % cf)
343            os.remove(cf)
[8cab4c2]344            self.log.info("ReleaseAccess succeeded for %s" % fid)
[e83f2f2]345            return { 'allocID': req['allocID'], 'proof': proof.to_dict() } 
[8cf2b90e]346        else:
347            self.state_lock.release()
348            raise service_error(service_error.req, "No such allocation")
[866c983]349
[06cc65b]350    # These are subroutines for StartSegment
[8cf2b90e]351    def generate_ns2(self, topo, expfn, softdir, connInfo):
[06cc65b]352        """
353        Convert topo into an ns2 file, decorated with appropriate commands for
354        the particular testbed setup.  Convert all requests for software, etc
355        to point at the staged copies on this testbed and add the federation
356        startcommands.
357        """
[617592b]358        class dragon_commands:
359            """
360            Functor to spit out approrpiate dragon commands for nodes listed in
361            the connectivity description.  The constructor makes a dict mapping
362            dragon nodes to their parameters and the __call__ checks each
363            element in turn for membership.
364            """
365            def __init__(self, map):
366                self.node_info = map
367
368            def __call__(self, e):
369                s = ""
370                if isinstance(e, topdl.Computer):
[49051fb]371                    if self.node_info.has_key(e.name):
[fefa026]372                        info = self.node_info[e.name]
373                        for ifname, vlan, type in info:
[617592b]374                            for i in e.interface:
375                                if i.name == ifname:
376                                    addr = i.get_attribute('ip4_address')
377                                    subs = i.substrate[0]
378                                    break
379                            else:
380                                raise service_error(service_error.internal,
381                                        "No interface %s on element %s" % \
[49051fb]382                                                (ifname, e.name))
[1cf8e2c]383                            # XXX: do netmask right
[617592b]384                            if type =='link':
[06cc65b]385                                s = ("tb-allow-external ${%s} " + \
386                                        "dragonportal ip %s vlan %s " + \
387                                        "netmask 255.255.255.0\n") % \
[e07c8f3]388                                        (topdl.to_tcl_name(e.name), addr, vlan)
[617592b]389                            elif type =='lan':
[06cc65b]390                                s = ("tb-allow-external ${%s} " + \
391                                        "dragonportal " + \
[617592b]392                                        "ip %s vlan %s usurp %s\n") % \
[e07c8f3]393                                        (topdl.to_tcl_name(e.name), addr, 
394                                                vlan, subs)
[617592b]395                            else:
396                                raise service_error(service_error_internal,
397                                        "Unknown DRAGON type %s" % type)
398                return s
399
400        class not_dragon:
[06cc65b]401            """
402            Return true if a node is in the given map of dragon nodes.
403            """
[617592b]404            def __init__(self, map):
405                self.nodes = set(map.keys())
406
407            def __call__(self, e):
[49051fb]408                return e.name not in self.nodes
[69692a9]409
[4d68ba6]410        def have_portals(top):
411            '''
412            Return true if the topology has a portal node
413            '''
414            # The else is on the for
415            for e in top.elements:
416                if isinstance(e, topdl.Computer) and e.get_attribute('portal'):
417                    return True
418            else:
419                return False
420
421
[06cc65b]422        # Main line of generate_ns2
[ecca6eb]423        t = topo.clone()
424
[06cc65b]425        # Create the map of nodes that need direct connections (dragon
426        # connections) from the connInfo
[617592b]427        dragon_map = { }
428        for i in [ i for i in connInfo if i['type'] == 'transit']:
429            for a in i.get('fedAttr', []):
430                if a['attribute'] == 'vlan_id':
431                    vlan = a['value']
432                    break
433            else:
[8cf2b90e]434                raise service_error(service_error.internal, "No vlan tag")
[617592b]435            members = i.get('member', [])
436            if len(members) > 1: type = 'lan'
437            else: type = 'link'
438
439            try:
440                for m in members:
[8cf2b90e]441                    if m['element'] in dragon_map:
[617592b]442                        dragon_map[m['element']].append(( m['interface'], 
443                            vlan, type))
444                    else:
445                        dragon_map[m['element']] = [( m['interface'], 
446                            vlan, type),]
447            except KeyError:
448                raise service_error(service_error.req,
449                        "Missing connectivity info")
450
[35aa3ae]451        # Weed out the things we aren't going to instantiate: Segments, portal
452        # substrates, and portal interfaces.  (The copy in the for loop allows
453        # us to delete from e.elements in side the for loop).  While we're
454        # touching all the elements, we also adjust paths from the original
455        # testbed to local testbed paths and put the federation commands into
456        # the start commands
[4d68ba6]457        local = len(dragon_map) == 0 and not have_portals(t)
458        if local: routing = 'Static'
459        else: routing = 'Manual'
460
461        self.log.debug("Local: %s", local)
[35aa3ae]462        for e in [e for e in t.elements]:
463            if isinstance(e, topdl.Segment):
464                t.elements.remove(e)
[43649f1]465            if isinstance(e, topdl.Computer):
[e76f38a]466                self.add_kit(e, self.federation_software)
[5e1fb7b]467                if e.get_attribute('portal') and self.portal_startcommand:
[9b3627e]468                    # Add local portal support software
[e76f38a]469                    self.add_kit(e, self.portal_software)
[43649f1]470                    # Portals never have a user-specified start command
[5e1fb7b]471                    e.set_attribute('startup', self.portal_startcommand)
[4d68ba6]472                elif not local and self.node_startcommand:
[43649f1]473                    if e.get_attribute('startup'):
[d87778f]474                        e.set_attribute('startup', "%s \\$USER '%s'" % \
[5e1fb7b]475                                (self.node_startcommand, 
476                                    e.get_attribute('startup')))
[43649f1]477                    else:
[5e1fb7b]478                        e.set_attribute('startup', self.node_startcommand)
[0297248]479
[49051fb]480                dinf = [i[0] for i in dragon_map.get(e.name, []) ]
[35aa3ae]481                # Remove portal interfaces that do not connect to DRAGON
482                e.interface = [i for i in e.interface \
[617592b]483                        if not i.get_attribute('portal') or i.name in dinf ]
[9b3627e]484            # Fix software paths
485            for s in getattr(e, 'software', []):
486                s.location = re.sub("^.*/", softdir, s.location)
[35aa3ae]487
488        t.substrates = [ s.clone() for s in t.substrates ]
489        t.incorporate_elements()
[ecca6eb]490
491        # Customize the ns2 output for local portal commands and images
492        filters = []
493
[5e1fb7b]494        if self.dragon_endpoint:
[617592b]495            add_filter = not_dragon(dragon_map)
496            filters.append(dragon_commands(dragon_map))
[69692a9]497        else:
498            add_filter = None
499
[5e1fb7b]500        if self.portal_command:
501            filters.append(topdl.generate_portal_command_filter(
502                self.portal_command, add_filter=add_filter))
[ecca6eb]503
[5e1fb7b]504        if self.portal_image:
[ecca6eb]505            filters.append(topdl.generate_portal_image_filter(
[5e1fb7b]506                self.portal_image))
[ecca6eb]507
[5e1fb7b]508        if self.portal_type:
[ecca6eb]509            filters.append(topdl.generate_portal_hardware_filter(
[5e1fb7b]510                self.portal_type))
[ecca6eb]511
512        # Convert to ns and write it out
[4d68ba6]513        expfile = topdl.topology_to_ns2(t, filters, routing=routing)
[ecca6eb]514        try:
515            f = open(expfn, "w")
516            print >>f, expfile
517            f.close()
[d3c8759]518        except EnvironmentError:
[ecca6eb]519            raise service_error(service_error.internal,
520                    "Cannot write experiment file %s: %s" % (expfn,e))
[f9ef40b]521
[2c1fd21]522    def export_store_info(self, cf, proj, ename, connInfo):
523        """
524        For the export requests in the connection info, install the peer names
525        at the experiment controller via SetValue calls.
526        """
527
528        for c in connInfo:
529            for p in [ p for p in c.get('parameter', []) \
530                    if p.get('type', '') == 'output']:
531
532                if p.get('name', '') == 'peer':
533                    k = p.get('key', None)
534                    surl = p.get('store', None)
535                    if surl and k and k.index('/') != -1:
536                        value = "%s.%s.%s%s" % \
537                                (k[k.index('/')+1:], ename, proj, self.domain)
538                        req = { 'name': k, 'value': value }
539                        self.log.debug("Setting %s to %s on %s" % \
540                                (k, value, surl))
541                        self.call_SetValue(surl, req, cf)
542                    else:
543                        self.log.error("Bad export request: %s" % p)
544                elif p.get('name', '') == 'ssh_port':
545                    k = p.get('key', None)
546                    surl = p.get('store', None)
547                    if surl and k:
548                        req = { 'name': k, 'value': self.ssh_port }
549                        self.log.debug("Setting %s to %s on %s" % \
550                                (k, self.ssh_port, surl))
551                        self.call_SetValue(surl, req, cf)
552                    else:
553                        self.log.error("Bad export request: %s" % p)
554                else:
555                    self.log.error("Unknown export parameter: %s" % \
556                            p.get('name'))
557                    continue
558
[49051fb]559    def add_seer_node(self, topo, name, startup):
560        """
561        Add a seer node to the given topology, with the startup command passed
[06cc65b]562        in.  Used by configure seer_services.
[49051fb]563        """
564        c_node = topdl.Computer(
565                name=name, 
566                os= topdl.OperatingSystem(
567                    attribute=[
568                    { 'attribute': 'osid', 
569                        'value': self.local_seer_image },
570                    ]),
571                attribute=[
572                    { 'attribute': 'startup', 'value': startup },
573                    ]
574                )
575        self.add_kit(c_node, self.local_seer_software)
576        topo.elements.append(c_node)
577
578    def configure_seer_services(self, services, topo, softdir):
[8cf2b90e]579        """
580        Make changes to the topology required for the seer requests being made.
581        Specifically, add any control or master nodes required and set up the
582        start commands on the nodes to interconnect them.
583        """
584        local_seer = False      # True if we need to add a control node
585        collect_seer = False    # True if there is a seer-master node
586        seer_master= False      # True if we need to add the seer-master
[49051fb]587        for s in services:
588            s_name = s.get('name', '')
589            s_vis = s.get('visibility','')
590
[8cf2b90e]591            if s_name  == 'local_seer_control' and s_vis == 'export':
[49051fb]592                local_seer = True
593            elif s_name == 'seer_master':
594                if s_vis == 'import':
595                    collect_seer = True
596                elif s_vis == 'export':
597                    seer_master = True
598       
599        # We've got the whole picture now, so add nodes if needed and configure
600        # them to interconnect properly.
601        if local_seer or seer_master:
602            # Copy local seer control node software to the tempdir
603            for l, f in self.local_seer_software:
604                base = os.path.basename(f)
605                copy_file(f, "%s/%s" % (softdir, base))
606        # If we're collecting seers somewhere the controllers need to talk to
607        # the master.  In testbeds that export the service, that will be a
608        # local node that we'll add below.  Elsewhere it will be the control
609        # portal that will port forward to the exporting master.
610        if local_seer:
611            if collect_seer:
[acaa9b9]612                startup = "%s -C %s" % (self.local_seer_start, "seer-master")
[49051fb]613            else:
614                startup = self.local_seer_start
615            self.add_seer_node(topo, 'control', startup)
616        # If this is the seer master, add that node, too.
617        if seer_master:
[e07c8f3]618            self.add_seer_node(topo, 'seer-master', 
619                    "%s -R -n -R seer-master -R -A -R sink" % \
620                            self.seer_master_start)
[49051fb]621
[06cc65b]622    def retrieve_software(self, topo, certfile, softdir):
623        """
624        Collect the software that nodes in the topology need loaded and stage
625        it locally.  This implies retrieving it from the experiment_controller
626        and placing it into softdir.  Certfile is used to prove that this node
627        has access to that data (it's the allocation/segment fedid).  Finally
628        local portal and federation software is also copied to the same staging
629        directory for simplicity - all software needed for experiment creation
630        is in softdir.
631        """
632        sw = set()
633        for e in topo.elements:
634            for s in getattr(e, 'software', []):
635                sw.add(s.location)
636        for s in sw:
637            self.log.debug("Retrieving %s" % s)
638            try:
639                get_url(s, certfile, softdir)
640            except:
641                t, v, st = sys.exc_info()
642                raise service_error(service_error.internal,
643                        "Error retrieving %s: %s" % (s, v))
[49051fb]644
[06cc65b]645        # Copy local federation and portal node software to the tempdir
646        for s in (self.federation_software, self.portal_software):
647            for l, f in s:
648                base = os.path.basename(f)
649                copy_file(f, "%s/%s" % (softdir, base))
[49051fb]650
[6c57fe9]651
[06cc65b]652    def initialize_experiment_info(self, attrs, aid, certfile, tmpdir):
653        """
654        Gather common configuration files, retrieve or create an experiment
655        name and project name, and return the ssh_key filenames.  Create an
656        allocation log bound to the state log variable as well.
657        """
[3df9b33]658        configs = ('hosts', 'ssh_pubkey', 'ssh_secretkey', 
659                'seer_ca_pem', 'seer_node_pem')
[06cc65b]660        ename = None
661        pubkey_base = None
662        secretkey_base = None
663        proj = None
664        user = None
665        alloc_log = None
[05c41f5]666        nonce_experiment = False
[262328f]667        vchars_re = '[^' + string.ascii_letters + string.digits  + '-]'
[06cc65b]668
[f3898f7]669        self.state_lock.acquire()
670        if aid in self.allocation:
671            proj = self.allocation[aid].get('project', None)
672        self.state_lock.release()
673
674        if not proj:
675            raise service_error(service_error.internal, 
676                    "Can't find project for %s" %aid)
677
[06cc65b]678        for a in attrs:
679            if a['attribute'] in configs:
680                try:
681                    self.log.debug("Retrieving %s from %s" % \
682                            (a['attribute'], a['value']))
683                    get_url(a['value'], certfile, tmpdir)
684                except:
685                    t, v, st = sys.exc_info()
686                    raise service_error(service_error.internal,
687                            "Error retrieving %s: %s" % (a.get('value', ""), v))
688            if a['attribute'] == 'ssh_pubkey':
689                pubkey_base = a['value'].rpartition('/')[2]
690            if a['attribute'] == 'ssh_secretkey':
691                secretkey_base = a['value'].rpartition('/')[2]
692            if a['attribute'] == 'experiment_name':
693                ename = a['value']
694
[f3898f7]695        # Names longer than the emulab max are discarded
[c167378]696        if ename and len(ename) <= self.max_name_len:
[262328f]697            # Clean up the experiment name so that emulab will accept it.
698            ename = re.sub(vchars_re, '-', ename)
699
700        else:
[06cc65b]701            ename = ""
702            for i in range(0,5):
703                ename += random.choice(string.ascii_letters)
[05c41f5]704            nonce_experiment = True
[53b5c18]705            self.log.warn("No experiment name or suggestion too long: " + \
706                    "picked one randomly: %s" % ename)
[06cc65b]707
708        if not pubkey_base:
709            raise service_error(service_error.req, 
710                    "No public key attribute")
711
712        if not secretkey_base:
713            raise service_error(service_error.req, 
714                    "No secret key attribute")
[6c57fe9]715
[06cc65b]716        self.state_lock.acquire()
717        if aid in self.allocation:
718            user = self.allocation[aid].get('user', None)
[f77a256]719            cert = self.allocation[aid].get('cert', None)
[06cc65b]720            self.allocation[aid]['experiment'] = ename
[05c41f5]721            self.allocation[aid]['nonce'] = nonce_experiment
[06cc65b]722            self.allocation[aid]['log'] = [ ]
723            # Create a logger that logs to the experiment's state object as
724            # well as to the main log file.
725            alloc_log = logging.getLogger('fedd.access.%s' % ename)
726            h = logging.StreamHandler(
727                    list_log.list_log(self.allocation[aid]['log']))
728            # XXX: there should be a global one of these rather than
729            # repeating the code.
730            h.setFormatter(logging.Formatter(
731                "%(asctime)s %(name)s %(message)s",
732                        '%d %b %y %H:%M:%S'))
733            alloc_log.addHandler(h)
734            self.write_state()
735        self.state_lock.release()
736
737        if not user:
738            raise service_error(service_error.internal, 
739                    "Can't find creation user for %s" %aid)
740
[f77a256]741        return (ename, proj, user, cert, pubkey_base, secretkey_base, alloc_log)
[06cc65b]742
[6e33086]743    def decorate_topology(self, info, t):
[06cc65b]744        """
[6e33086]745        Copy the physical mapping and status onto the topology.  Used by
746        StartSegment and InfoSegment
[06cc65b]747        """
[6e33086]748        def add_new(ann, attr):
749            for a in ann:
750                if a not in attr: attr.append(a)
751
[cebcdce]752        def merge_os(os, e):
753            if len(e.os) == 0:
754                # No OS at all:
755                if os.get_attribute('emulab_access:image'):
756                    os.set_attribute('emulab_access:initial_image', 
757                            os.get_attribute('emulab_access:image'))
758                e.os = [ os ]
759            elif len(e.os) == 1:
760                # There's one OS, copy the initial image and replace
761                eos = e.os[0]
762                initial = eos.get_attribute('emulab_access:initial_image')
763                if initial:
764                    os.set_attribute('emulab_access:initial_image', initial)
765                e.os = [ os] 
766            else:
767                # Multiple OSes, replace or append
768                for eos in e.os:
769                    if os.name == eos.name:
770                        eos.version = os.version
771                        eos.version = os.distribution
772                        eos.version = os.distributionversion
773                        for a in os.attribute:
774                            if eos.get_attribute(a.attribute):
775                                eos.remove_attribute(a.attribute)
776                            eos.set_attribute(a.attribute, a.value)
777                        break
778                else:
779                    e.os.append(os)
780
781
782        if t is None: return
[7653f01]783        i = 0 # For fake debugging instantiation
[06cc65b]784        # Copy the assigned names into the return topology
[c6f867c]785        for e in t.elements:
[29d5f7c]786            if isinstance(e, topdl.Computer):
787                if not self.create_debug:
[6e33086]788                    if e.name in info.node:
[1ae1aa2]789                        add_new(("%s%s" % 
790                            (info.node[e.name].pname, self.domain),),
791                            e.localname)
[6527d60]792                        add_new(("%s%s" % 
793                            (info.node[e.name].lname, self.domain),),
794                            e.localname)
[1ae1aa2]795                        e.status = info.node[e.name].status
796                        os = info.node[e.name].getOS()
[cebcdce]797                        if os: merge_os(os, e)
[29d5f7c]798                else:
799                    # Simple debugging assignment
[6e33086]800                    add_new(("node%d%s" % (i, self.domain),), e.localname)
[29d5f7c]801                    e.status = 'active'
[6e33086]802                    add_new(('testop1', 'testop2'), e.operation)
[29d5f7c]803                    i += 1
804
[6527d60]805        for s in t.substrates:
806            if s.name in info.subs:
807                sub = info.subs[s.name]
808                if sub.cap is not None:
809                    s.capacity = topdl.Capacity(sub.cap, 'max')
810                if sub.delay is not None:
811                    s.delay = topdl.Latency(sub.delay, 'max')
812        # XXX interfaces
813
[6e33086]814
815    def finalize_experiment(self, starter, topo, aid, alloc_id, proof):
816        """
817        Store key bits of experiment state in the global repository, including
818        the response that may need to be replayed, and return the response.
819        """
820        i = 0
821        t = topo.clone()
822        self.decorate_topology(starter, t)
[06cc65b]823        # Grab the log (this is some anal locking, but better safe than
824        # sorry)
825        self.state_lock.acquire()
826        logv = "".join(self.allocation[aid]['log'])
827        # It's possible that the StartSegment call gets retried (!).
828        # if the 'started' key is in the allocation, we'll return it rather
829        # than redo the setup.
830        self.allocation[aid]['started'] = { 
831                'allocID': alloc_id,
832                'allocationLog': logv,
833                'segmentdescription': { 
[b709861]834                    'topdldescription': t.to_dict()
[06cc65b]835                    },
[e83f2f2]836                'proof': proof.to_dict(),
[06cc65b]837                }
[b709861]838        self.allocation[aid]['topo'] = t
[06cc65b]839        retval = copy.copy(self.allocation[aid]['started'])
840        self.write_state()
841        self.state_lock.release()
842        return retval
843   
844    # End of StartSegment support routines
845
846    def StartSegment(self, req, fid):
[b770aa0]847        err = None  # Any service_error generated after tmpdir is created
848        rv = None   # Return value from segment creation
849
[8cab4c2]850        self.log.info("StartSegment called by %s" % fid)
[cc8d8e9]851        try:
852            req = req['StartSegmentRequestBody']
[06cc65b]853            auth_attr = req['allocID']['fedid']
854            topref = req['segmentdescription']['topdldescription']
[cc8d8e9]855        except KeyError:
856            raise service_error(server_error.req, "Badly formed request")
[ecca6eb]857
[e02cd14]858        connInfo = req.get('connection', [])
859        services = req.get('service', [])
[ecca6eb]860        aid = "%s" % auth_attr
[6c57fe9]861        attrs = req.get('fedAttr', [])
[e83f2f2]862
863        access_ok, proof = self.auth.check_attribute(fid, auth_attr, 
864                with_proof=True)
865        if not access_ok:
[8cab4c2]866            self.log.info("StartSegment for %s failed: access denied" % fid)
[ecca6eb]867            raise service_error(service_error.access, "Access denied")
[cd06678]868        else:
869            # See if this is a replay of an earlier succeeded StartSegment -
870            # sometimes SSL kills 'em.  If so, replay the response rather than
871            # redoing the allocation.
872            self.state_lock.acquire()
873            retval = self.allocation[aid].get('started', None)
874            self.state_lock.release()
875            if retval:
876                self.log.warning("Duplicate StartSegment for %s: " % aid + \
877                        "replaying response")
878                return retval
879
880        # A new request.  Do it.
[6c57fe9]881
[06cc65b]882        if topref: topo = topdl.Topology(**topref)
[6c57fe9]883        else:
884            raise service_error(service_error.req, 
885                    "Request missing segmentdescription'")
[2761484]886       
[6c57fe9]887        certfile = "%s/%s.pem" % (self.certdir, auth_attr)
888        try:
889            tmpdir = tempfile.mkdtemp(prefix="access-")
[ecca6eb]890            softdir = "%s/software" % tmpdir
[c23684d]891            os.mkdir(softdir)
[d3c8759]892        except EnvironmentError:
[8cab4c2]893            self.log.info("StartSegment for %s failed: internal error" % fid)
[6c57fe9]894            raise service_error(service_error.internal, "Cannot create tmp dir")
895
[b770aa0]896        # Try block alllows us to clean up temporary files.
897        try:
[06cc65b]898            self.retrieve_software(topo, certfile, softdir)
[f77a256]899            ename, proj, user, xmlrpc_cert, pubkey_base, secretkey_base, \
900                alloc_log =  self.initialize_experiment_info(attrs, aid, 
901                        certfile, tmpdir)
[c0f409a]902            # A misconfigured cert in the ABAC map can be confusing...
903            if not os.access(xmlrpc_cert, os.R_OK):
904                self.log.error("Cannot open user's emulab SSL cert: %s" % \
905                        xmlrpc_cert)
906                raise service_error(service_error.internal,
907                        "Cannot open user's emulab SSL cert: %s" % xmlrpc_cert)
908
[9b3627e]909
[f3898f7]910            if '/' in proj: proj, gid = proj.split('/')
911            else: gid = None
912
913
[06cc65b]914            # Set up userconf and seer if needed
[c200d36]915            self.configure_userconf(services, tmpdir)
[49051fb]916            self.configure_seer_services(services, topo, softdir)
[06cc65b]917            # Get and send synch store variables
[2761484]918            self.export_store_info(certfile, proj, ename, connInfo)
919            self.import_store_info(certfile, connInfo)
920
[b770aa0]921            expfile = "%s/experiment.tcl" % tmpdir
922
923            self.generate_portal_configs(topo, pubkey_base, 
[06cc65b]924                    secretkey_base, tmpdir, proj, ename, connInfo, services)
[b770aa0]925            self.generate_ns2(topo, expfile, 
[8cf2b90e]926                    "/proj/%s/software/%s/" % (proj, ename), connInfo)
[f07fa49]927
[b770aa0]928            starter = self.start_segment(keyfile=self.ssh_privkey_file, 
[181aeb4]929                    debug=self.create_debug, log=alloc_log, boss=self.boss,
[f77a256]930                    ops=self.ops, cert=xmlrpc_cert)
[f3898f7]931            rv = starter(self, ename, proj, user, expfile, tmpdir, gid=gid)
[b770aa0]932        except service_error, e:
[8cab4c2]933            self.log.info("StartSegment for %s failed: %s"  % (fid, e))
[b770aa0]934            err = e
[06cc65b]935        except:
936            t, v, st = sys.exc_info()
[5f6ebd0]937            self.log.info("StartSegment for %s failed:unexpected error: %s" \
938                    % (fid, traceback.extract_tb(st)))
[06cc65b]939            err = service_error(service_error.internal, "%s: %s" % \
940                    (v, traceback.extract_tb(st)))
[b770aa0]941
[574055e]942        # Walk up tmpdir, deleting as we go
[06cc65b]943        if self.cleanup: self.remove_dirs(tmpdir)
944        else: self.log.debug("[StartSegment]: not removing %s" % tmpdir)
[574055e]945
[fd556d1]946        if rv:
[8cab4c2]947            self.log.info("StartSegment for %s succeeded" % fid)
[e83f2f2]948            return self.finalize_experiment(starter, topo, aid, req['allocID'],
949                    proof)
[b770aa0]950        elif err:
951            raise service_error(service_error.federant,
952                    "Swapin failed: %s" % err)
[fd556d1]953        else:
954            raise service_error(service_error.federant, "Swapin failed")
[5ae3857]955
956    def TerminateSegment(self, req, fid):
[8cab4c2]957        self.log.info("TerminateSegment called by %s" % fid)
[5ae3857]958        try:
959            req = req['TerminateSegmentRequestBody']
960        except KeyError:
961            raise service_error(server_error.req, "Badly formed request")
962
963        auth_attr = req['allocID']['fedid']
964        aid = "%s" % auth_attr
965        attrs = req.get('fedAttr', [])
[e83f2f2]966
967        access_ok, proof = self.auth.check_attribute(fid, auth_attr, 
968                with_proof=True)
969        if not access_ok:
[5ae3857]970            raise service_error(service_error.access, "Access denied")
971
972        self.state_lock.acquire()
[06cc65b]973        if aid in self.allocation:
[5ae3857]974            proj = self.allocation[aid].get('project', None)
975            user = self.allocation[aid].get('user', None)
[f77a256]976            xmlrpc_cert = self.allocation[aid].get('cert', None)
[5ae3857]977            ename = self.allocation[aid].get('experiment', None)
[05c41f5]978            nonce = self.allocation[aid].get('nonce', False)
[1d913e13]979        else:
980            proj = None
981            user = None
982            ename = None
[05c41f5]983            nonce = False
[f77a256]984            xmlrpc_cert = None
[5ae3857]985        self.state_lock.release()
986
987        if not proj:
[8cab4c2]988            self.log.info("TerminateSegment failed for %s: cannot find project"\
989                    % fid)
[5ae3857]990            raise service_error(service_error.internal, 
991                    "Can't find project for %s" % aid)
[f3898f7]992        else:
993            if '/' in proj: proj, gid = proj.split('/')
994            else: gid = None
[5ae3857]995
996        if not user:
[8cab4c2]997            self.log.info("TerminateSegment failed for %s: cannot find user"\
998                    % fid)
[5ae3857]999            raise service_error(service_error.internal, 
1000                    "Can't find creation user for %s" % aid)
1001        if not ename:
[8cab4c2]1002            self.log.info(
1003                    "TerminateSegment failed for %s: cannot find experiment"\
1004                    % fid)
[5ae3857]1005            raise service_error(service_error.internal, 
1006                    "Can't find experiment name for %s" % aid)
[fd556d1]1007        stopper = self.stop_segment(keyfile=self.ssh_privkey_file,
[c7141dc]1008                debug=self.create_debug, boss=self.boss, ops=self.ops,
[f77a256]1009                cert=xmlrpc_cert)
[05c41f5]1010        stopper(self, user, proj, ename, gid, nonce)
[8cab4c2]1011        self.log.info("TerminateSegment succeeded for %s %s %s" % \
1012                (fid, proj, ename))
[e83f2f2]1013        return { 'allocID': req['allocID'], 'proof': proof.to_dict() }
[45e880d]1014
1015    def InfoSegment(self, req, fid):
[8cab4c2]1016        self.log.info("InfoSegment called by %s" % fid)
[45e880d]1017        try:
1018            req = req['InfoSegmentRequestBody']
1019        except KeyError:
1020            raise service_error(server_error.req, "Badly formed request")
1021
1022        auth_attr = req['allocID']['fedid']
1023        aid = "%s" % auth_attr
1024
1025        access_ok, proof = self.auth.check_attribute(fid, auth_attr, 
1026                with_proof=True)
1027        if not access_ok:
1028            raise service_error(service_error.access, "Access denied")
1029
1030        self.state_lock.acquire()
1031        if aid in self.allocation:
1032            topo = self.allocation[aid].get('topo', None)
1033            if topo: topo = topo.clone()
1034            proj = self.allocation[aid].get('project', None)
1035            user = self.allocation[aid].get('user', None)
[f77a256]1036            xmlrpc_cert = self.allocation[aid].get('cert', None)
[45e880d]1037            ename = self.allocation[aid].get('experiment', None)
1038        else:
1039            proj = None
1040            user = None
1041            ename = None
[b709861]1042            topo = None
[f77a256]1043            xmlrpc_cert = None
[45e880d]1044        self.state_lock.release()
1045
1046        if not proj:
[8cab4c2]1047            self.log.info("InfoSegment failed for %s: cannot find project"% fid)
[45e880d]1048            raise service_error(service_error.internal, 
1049                    "Can't find project for %s" % aid)
1050        else:
1051            if '/' in proj: proj, gid = proj.split('/')
1052            else: gid = None
1053
1054        if not user:
[8cab4c2]1055            self.log.info("InfoSegment failed for %s: cannot find user"% fid)
[45e880d]1056            raise service_error(service_error.internal, 
1057                    "Can't find creation user for %s" % aid)
1058        if not ename:
[8cab4c2]1059            self.log.info("InfoSegment failed for %s: cannot find exp"% fid)
[45e880d]1060            raise service_error(service_error.internal, 
1061                    "Can't find experiment name for %s" % aid)
1062        info = self.info_segment(keyfile=self.ssh_privkey_file,
[c7141dc]1063                debug=self.create_debug, boss=self.boss, ops=self.ops,
[f77a256]1064                cert=xmlrpc_cert)
[6e33086]1065        info(self, user, proj, ename)
[8cab4c2]1066        self.log.info("InfoSegment gathered info for %s %s %s %s" % \
1067                (fid, user, proj, ename))
[6e33086]1068        self.decorate_topology(info, topo)
[1ae1aa2]1069        self.state_lock.acquire()
1070        if aid in self.allocation:
1071            self.allocation[aid]['topo'] = topo
1072            self.write_state()
1073        self.state_lock.release()
[8cab4c2]1074        self.log.info("InfoSegment updated info for %s %s %s %s" % \
1075                (fid, user, proj, ename))
[1ae1aa2]1076
[7f57435]1077        rv = { 
[45e880d]1078                'allocID': req['allocID'], 
1079                'proof': proof.to_dict(),
1080                }
[8cab4c2]1081        self.log.info("InfoSegment succeeded info for %s %s %s %s" % \
1082                (fid, user, proj, ename))
[7f57435]1083        if topo:
1084            rv['segmentdescription'] = { 'topdldescription' : topo.to_dict() }
1085        return rv
[b709861]1086
1087    def OperationSegment(self, req, fid):
1088        def get_pname(e):
1089            """
1090            Get the physical name of a node
1091            """
1092            if e.localname:
1093                return re.sub('\..*','', e.localname[0])
1094            else:
1095                return None
1096
[8cab4c2]1097        self.log.info("OperationSegment called by %s" % fid)
[b709861]1098        try:
1099            req = req['OperationSegmentRequestBody']
1100        except KeyError:
1101            raise service_error(server_error.req, "Badly formed request")
1102
1103        auth_attr = req['allocID']['fedid']
1104        aid = "%s" % auth_attr
1105
1106        access_ok, proof = self.auth.check_attribute(fid, auth_attr, 
1107                with_proof=True)
1108        if not access_ok:
[8cab4c2]1109            self.log.info("OperationSegment failed for %s: access denied" % fid)
[b709861]1110            raise service_error(service_error.access, "Access denied")
1111
1112        op = req.get('operation', None)
1113        targets = req.get('target', None)
1114        params = req.get('parameter', None)
1115
1116        if op is None :
[8cab4c2]1117            self.log.info("OperationSegment failed for %s: no operation" % fid)
[b709861]1118            raise service_error(service_error.req, "missing operation")
1119        elif targets is None:
[8cab4c2]1120            self.log.info("OperationSegment failed for %s: no targets" % fid)
[b709861]1121            raise service_error(service_error.req, "no targets")
1122
[f77a256]1123        self.state_lock.acquire()
[b709861]1124        if aid in self.allocation:
1125            topo = self.allocation[aid].get('topo', None)
1126            if topo: topo = topo.clone()
[f77a256]1127            xmlrpc_cert = self.allocation[aid].get('cert', None)
[b709861]1128        else:
1129            topo = None
[f77a256]1130            xmlrpc_cert = None
1131        self.state_lock.release()
[b709861]1132
1133        targets = copy.copy(targets)
1134        ptargets = { }
1135        for e in topo.elements:
1136            if isinstance(e, topdl.Computer):
1137                if e.name in targets:
1138                    targets.remove(e.name)
1139                    pn = get_pname(e)
1140                    if pn: ptargets[e.name] = pn
1141
1142        status = [ operation_status(t, operation_status.no_target) \
1143                for t in targets]
1144
1145        ops = self.operation_segment(keyfile=self.ssh_privkey_file,
[c7141dc]1146                debug=self.create_debug, boss=self.boss, ops=self.ops,
[f77a256]1147                cert=xmlrpc_cert)
[1ae1aa2]1148        ops(self, op, ptargets, params, topo)
[8cab4c2]1149        self.log.info("OperationSegment operated for %s" % fid)
[b709861]1150       
1151        status.extend(ops.status)
[8cab4c2]1152        self.log.info("OperationSegment succeed for %s" % fid)
[b709861]1153
1154        return { 
1155                'allocID': req['allocID'], 
1156                'status': [s.to_dict() for s in status],
1157                'proof': proof.to_dict(),
1158                }
Note: See TracBrowser for help on using the repository browser.