source: fedd/federation/access.py @ 2c1fd21

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

Importing is a relatively access semantics free endeavour, but export needs to be in each access controller.

  • Property mode set to 100644
File size: 22.3 KB
Line 
1#!/usr/local/bin/python
2
3import os,sys
4import stat # for chmod constants
5import re
6import random
7import string
8import copy
9import pickle
10import logging
11import subprocess
12
13from threading import *
14from M2Crypto.SSL import SSLError
15
16from util import *
17from allocate_project import allocate_project_local, allocate_project_remote
18from access_project import access_project
19from fedid import fedid, generate_fedid
20from authorizer import authorizer
21from service_error import service_error
22from remote_service import xmlrpc_handler, soap_handler, service_caller
23
24import httplib
25import tempfile
26from urlparse import urlparse
27
28import topdl
29import list_log
30import proxy_emulab_segment
31import local_emulab_segment
32
33
34# Make log messages disappear if noone configures a fedd logger
35class nullHandler(logging.Handler):
36    def emit(self, record): pass
37
38fl = logging.getLogger("fedd.access")
39fl.addHandler(nullHandler())
40
41class access_base:
42    """
43    The implementation of access control based on mapping users to projects.
44
45    Users can be mapped to existing projects or have projects created
46    dynamically.  This implements both direct requests and proxies.
47    """
48
49    class parse_error(RuntimeError): pass
50
51    def __init__(self, config=None, auth=None):
52        """
53        Initializer.  Pulls parameters out of the ConfigParser's access section.
54        """
55
56        # Make sure that the configuration is in place
57        if not config: 
58            raise RunTimeError("No config to fedd.access")
59
60        self.project_priority = config.getboolean("access", "project_priority")
61
62        self.certdir = config.get("access","certdir")
63        self.create_debug = config.getboolean("access", "create_debug")
64        self.cleanup = not config.getboolean("access", "leave_tmpfiles")
65        self.access_type = config.get("access", "type")
66        self.log = logging.getLogger("fedd.access")
67        set_log_level(config, "access", self.log)
68        self.state_lock = Lock()
69        self.state = { }
70        # XXX: Configurable
71        self.exports = set(('SMB', 'seer', 'tmcd', 'userconfig', 
72            'project_export', 'local_seer_control', 'seer_master', 
73            'hide_hosts'))
74        self.imports = set(('SMB', 'seer', 'userconfig', 'seer_master',
75            'hide_hosts'))
76
77        if auth: self.auth = auth
78        else:
79            self.log.error(\
80                    "[access]: No authorizer initialized, creating local one.")
81            auth = authorizer()
82
83        self.state_filename = config.get("access", "access_state")
84        self.read_state()
85
86        # Keep cert_file and cert_pwd coming from the same place
87        self.cert_file = config.get("access", "cert_file")
88        if self.cert_file:
89            self.sert_pwd = config.get("access", "cert_pw")
90        else:
91            self.cert_file = config.get("globals", "cert_file")
92            self.sert_pwd = config.get("globals", "cert_pw")
93
94        self.trusted_certs = config.get("access", "trusted_certs") or \
95                config.get("globals", "trusted_certs")
96
97
98    @staticmethod
99    def software_list(v):
100        """
101        From a string containing a sequence of space separated pairs, return a
102        list of tuples with pairs of location and file.
103        """
104        l = [ ]
105        if v:
106            ps = v.split(" ")
107            while len(ps):
108                loc, file = ps[0:2]
109                del ps[0:2]
110                l.append((loc, file))
111        return l
112
113    @staticmethod
114    def add_kit(e, kit):
115        """
116        Add a Software object created from the list of (install, location)
117        tuples passed as kit  to the software attribute of an object e.  We
118        do this enough to break out the code, but it's kind of a hack to
119        avoid changing the old tuple rep.
120        """
121
122        s = [ topdl.Software(install=i, location=l) for i, l in kit]
123
124        if isinstance(e.software, list): e.software.extend(s)
125        else: e.software = s
126
127
128    def read_access(self, config, access_obj=None):
129        """
130        Read an access DB with filename config  of the form:
131            (id, id, id) -> attribute, something
132        where the ids can be fedids, strings, or <any> or <none>, attribute is
133        the attribute to assign , and something is any set of charcters.  The
134        hash self.access is populated with mappings from those triples to the
135        results of access_obj being called on the remainder of the line (if
136        present).  If access_obj is not given, the string itself is entered in
137        the hash.  Additionally, a triple with <any> and <none> mapped to None
138        is entered in self.auth with the attribute given.
139
140        Parsing errors result in a self.parse_error exception being raised.
141        access_obj should throw that as well.
142        """
143        lineno=0
144        name_expr = "["+string.ascii_letters + string.digits + "\.\-_]+"
145        fedid_expr = "fedid:[" + string.hexdigits + "]+"
146        key_name = "(<ANY>|<NONE>|"+fedid_expr + "|"+ name_expr + ")"
147        access_re = re.compile('\('+key_name+'\s*,\s*'+key_name+'\s*,\s*'+
148                key_name+'\s*\)\s*->\s*([^,]+)\s*(.*)', re.IGNORECASE)
149
150        def parse_name(n):
151            if n.startswith('fedid:'): return fedid(hexstr=n[len('fedid:'):])
152            else: return n
153       
154        def auth_name(n):
155            if isinstance(n, basestring):
156                if n =='<any>' or n =='<none>': return None
157                else: return unicode(n)
158            else:
159                return n
160        def strip_comma(s):
161            s = s.strip()
162            if s.startswith(','):
163                s = s[1:].strip()
164            return s
165
166        if access_obj is None:
167            access_obj = lambda(x): "%s" % x
168
169        f = open(config, "r");
170        try:
171            for line in f:
172                lineno += 1
173                line = line.strip();
174                if len(line) == 0 or line.startswith('#'):
175                    continue
176
177                # Access line (t, p, u) -> anything
178                m = access_re.match(line)
179                if m != None:
180                    access_key = tuple([ parse_name(x) \
181                            for x in m.group(1,2,3)])
182                    attribute = m.group(4)
183                    auth_key = tuple([ auth_name(x) for x in access_key])
184                    self.auth.set_attribute(auth_key, attribute)
185                    if len(m.group(5)) > 0:
186                        access_val = access_obj(strip_comma(m.group(5)))
187                        self.access[access_key] = access_val
188                    continue
189
190                # Nothing matched to here: unknown line - raise exception
191                f.close()
192                raise self.parse_error(
193                        "Unknown statement at line %d of %s" % \
194                        (lineno, config))
195        finally:
196            if f: f.close()
197
198    def get_users(self, obj):
199        """
200        Return a list of the IDs of the users in dict
201        """
202        if obj.has_key('user'):
203            return [ unpack_id(u['userID']) \
204                    for u in obj['user'] if u.has_key('userID') ]
205        else:
206            return None
207
208    def write_state(self):
209        if self.state_filename:
210            try:
211                f = open(self.state_filename, 'w')
212                pickle.dump(self.state, f)
213                self.log.debug("Wrote state to %s" % self.state_filename)
214            except EnvironmentError, e:
215                self.log.error("Can't write file %s: %s" % \
216                        (self.state_filename, e))
217            except pickle.PicklingError, e:
218                self.log.error("Pickling problem: %s" % e)
219            except TypeError, e:
220                self.log.error("Pickling problem (TypeError): %s" % e)
221
222
223    def read_state(self):
224        """
225        Read a new copy of access state.  Old state is overwritten.
226
227        State format is a simple pickling of the state dictionary.
228        """
229        if self.state_filename:
230            try:
231                f = open(self.state_filename, "r")
232                self.state = pickle.load(f)
233                self.log.debug("[read_state]: Read state from %s" % \
234                        self.state_filename)
235            except EnvironmentError, e:
236                self.log.warning(("[read_state]: No saved state: " +\
237                        "Can't open %s: %s") % (self.state_filename, e))
238            except EOFError, e:
239                self.log.warning(("[read_state]: " +\
240                        "Empty or damaged state file: %s:") % \
241                        self.state_filename)
242            except pickle.UnpicklingError, e:
243                self.log.warning(("[read_state]: No saved state: " + \
244                        "Unpickling failed: %s") % e)
245
246
247
248    def permute_wildcards(self, a, p):
249        """Return a copy of a with various fields wildcarded.
250
251        The bits of p control the wildcards.  A set bit is a wildcard
252        replacement with the lowest bit being user then project then testbed.
253        """
254        if p & 1: user = ["<any>"]
255        else: user = a[2]
256        if p & 2: proj = "<any>"
257        else: proj = a[1]
258        if p & 4: tb = "<any>"
259        else: tb = a[0]
260
261        return (tb, proj, user)
262
263    def find_access(self, search):
264        """
265        Search the access DB for a match on this tuple.  Return the matching
266        access tuple and the user that matched.
267       
268        NB, if the initial tuple fails to match we start inserting wildcards in
269        an order determined by self.project_priority.  Try the list of users in
270        order (when wildcarded, there's only one user in the list).
271        """
272        if self.project_priority: perm = (0, 1, 2, 3, 4, 5, 6, 7)
273        else: perm = (0, 2, 1, 3, 4, 6, 5, 7)
274
275        for p in perm: 
276            s = self.permute_wildcards(search, p)
277            # s[2] is None on an anonymous, unwildcarded request
278            if s[2] != None:
279                for u in s[2]:
280                    if self.access.has_key((s[0], s[1], u)):
281                        return (self.access[(s[0], s[1], u)], u)
282            else:
283                if self.access.has_key(s):
284                    return (self.access[s], None)
285        return None, None
286
287    def lookup_access_base(self, req, fid):
288        """
289        Determine the allowed access for this request.  Return the access and
290        which fields are dynamic.
291
292        The fedid is needed to construct the request
293        """
294        user_re = re.compile("user:\s(.*)")
295        project_re = re.compile("project:\s(.*)")
296
297        # Search keys
298        tb = fid
299        user = [ user_re.findall(x)[0] for x in req.get('credential', []) \
300                if user_re.match(x)]
301        project = [ project_re.findall(x)[0] \
302                for x in req.get('credential', []) \
303                    if project_re.match(x)]
304
305        if len(project) == 1: project = project[0]
306        elif len(project) == 0: project = None
307        else: 
308            raise service_error(service_error.req, 
309                    "More than one project credential")
310
311        # Confirm authorization
312        for u in user:
313            self.log.debug("[lookup_access] Checking access for %s" % \
314                    ((tb, project, u),))
315            if self.auth.check_attribute((tb, project, u), 'access'):
316                self.log.debug("[lookup_access] Access granted")
317                break
318            else:
319                self.log.debug("[lookup_access] Access Denied")
320        else:
321            raise service_error(service_error.access, "Access denied")
322
323        # This maps a valid user to the Emulab projects and users to use
324        found, user_match = self.find_access((tb, project, user))
325
326        return (found, (tb, project, user_match))
327       
328
329    def get_handler(self, path, fid):
330        self.log.info("Get handler %s %s" % (path, fid))
331        if self.auth.check_attribute(fid, path) and self.userconfdir:
332            return ("%s/%s" % (self.userconfdir, path), "application/binary")
333        else:
334            return (None, None)
335
336    def export_userconf(self, project):
337        dev_null = None
338        confid, confcert = generate_fedid("test", dir=self.userconfdir, 
339                log=self.log)
340        conffilename = "%s/%s" % (self.userconfdir, str(confid))
341        cf = None
342        try:
343            cf = open(conffilename, "w")
344            os.chmod(conffilename, stat.S_IRUSR | stat.S_IWUSR)
345        except EnvironmentError, e:
346            raise service_error(service_error.internal, 
347                    "Cannot create user configuration data")
348
349        try:
350            dev_null = open("/dev/null", "a")
351        except EnvironmentError, e:
352            self.log.error("export_userconf: can't open /dev/null: %s" % e)
353
354        cmd = "%s %s" % (self.userconfcmd, project)
355        conf = subprocess.call(cmd.split(" "),
356                stdout=cf, stderr=dev_null, close_fds=True)
357
358        self.auth.set_attribute(confid, "/%s" % str(confid))
359
360        return confid, confcert
361
362    def export_SMB(self, id, state, project, user, attrs):
363        return { 
364                'id': id,
365                'name': 'SMB',
366                'visibility': 'export',
367                'server': 'http://fs:139',
368                'fedAttr': [
369                        { 'attribute': 'SMBSHARE', 'value': 'USERS' },
370                        { 'attribute': 'SMBUSER', 'value': user },
371                        { 'attribute': 'SMBPROJ', 'value': project },
372                    ]
373                }
374
375    def export_seer(self, id, state, project, user, attrs):
376        return { 
377                'id': id,
378                'name': 'seer',
379                'visibility': 'export',
380                'server': 'http://control:16606',
381                }
382
383    def export_local_seer(self, id, state, project, user, attrs):
384        return { 
385                'id': id,
386                'name': 'local_seer_control',
387                'visibility': 'export',
388                'server': 'http://control:16606',
389                }
390
391    def export_seer_master(self, id, state, project, user, attrs):
392        return { 
393                'id': id,
394                'name': 'seer_master',
395                'visibility': 'export',
396                'server': 'http://seer-master:17707',
397                }
398
399    def export_tmcd(self, id, state, project, user, attrs):
400        return { 
401                'id': id,
402                'name': 'seer',
403                'visibility': 'export',
404                'server': 'http://boss:7777',
405                }
406
407    def export_userconfig(self, id, state, project, user, attrs):
408        if self.userconfdir and self.userconfcmd \
409                and self.userconfurl:
410            cid, cert = self.export_userconf(project)
411            state['userconfig'] = unicode(cid)
412            return {
413                    'id': id,
414                    'name': 'userconfig',
415                    'visibility': 'export',
416                    'server': "%s/%s" % (self.userconfurl, str(cid)),
417                    'fedAttr': [
418                        { 'attribute': 'cert', 'value': cert },
419                    ]
420                    }
421        else:
422            return None
423
424    def export_hide_hosts(self, id, state, project, user, attrs):
425        return {
426                'id': id, 
427                'name': 'hide_hosts',
428                'visibility': 'export',
429                'fedAttr': [ x for x in attrs \
430                        if x.get('attribute', "") == 'hosts'],
431                }
432
433    def export_services(self, sreq, project, user):
434        exp = [ ]
435        state = { }
436        # XXX: Filthy shortcut here using http: so urlparse will give the right
437        # answers.
438        for s in sreq:
439            sname = s.get('name', '')
440            svis = s.get('visibility', '')
441            sattrs = s.get('fedAttr', [])
442            if svis == 'export':
443                if sname in self.exports:
444                    id = s.get('id', 'no_id')
445                    if sname == 'SMB':
446                        exp.append(self.export_SMB(id, state, project, user,
447                            sattrs))
448                    elif sname == 'seer':
449                        exp.append(self.export_seer(id, state, project, user,
450                            sattrs))
451                    elif sname == 'tmcd':
452                        exp.append(self.export_tmcd(id, state, project, user,
453                            sattrs))
454                    elif sname == 'userconfig':
455                        exp.append(self.export_userconfig(id, state,
456                            project, user, sattrs))
457                    elif sname == 'project_export':
458                        exp.append(self.export_SMB(id, state, project, user,
459                            sattrs))
460                        #exp.append(self.export_seer(id, state, project, user,
461                        #sattrs))
462                        exp.append(self.export_userconfig(id, state,
463                            project, user, sattrs))
464                    elif sname == 'local_seer_control':
465                        exp.append(self.export_local_seer(id, state, project,
466                            user, sattrs))
467                    elif sname == 'seer_master':
468                        exp.append(self.export_seer_master(id, state, project,
469                            user, sattrs))
470                    elif sname == 'hide_hosts':
471                        exp.append(self.export_hide_hosts(id, state, project,
472                            user, sattrs))
473        return (exp, state)
474
475    def build_access_response(self, alloc_id, ap, services):
476        """
477        Create the SOAP response.
478
479        Build the dictionary description of the response and use
480        fedd_utils.pack_soap to create the soap message.  ap is the allocate
481        project message returned from a remote project allocation (even if that
482        allocation was done locally).
483        """
484        # Because alloc_id is already a fedd_services_types.IDType_Holder,
485        # there's no need to repack it
486        msg = { 
487                'allocID': alloc_id,
488                'fedAttr': [
489                    { 'attribute': 'domain', 'value': self.domain } , 
490                    { 'attribute': 'project', 'value': 
491                        ap['project'].get('name', {}).get('localname', "???") },
492                ]
493            }
494
495        if self.dragon_endpoint:
496            msg['fedAttr'].append({'attribute': 'dragon',
497                'value': self.dragon_endpoint})
498        if self.deter_internal:
499            print 'adding internal'
500            msg['fedAttr'].append({'attribute': 'deter_internal',
501                'value': self.deter_internal})
502        else: print "internal: %s" % self.deter_internal
503        #XXX: ??
504        if self.dragon_vlans:
505            msg['fedAttr'].append({'attribute': 'vlans',
506                'value': self.dragon_vlans})
507
508        if services:
509            msg['service'] = services
510        return msg
511
512    def generate_portal_configs(self, topo, pubkey_base, secretkey_base, 
513            tmpdir, lproj, leid, connInfo, services):
514
515        def conninfo_to_dict(key, info):
516            """
517            Make a cpoy of the connection information about key, and flatten it
518            into a single dict by parsing out any feddAttrs.
519            """
520
521            rv = None
522            for i in info:
523                if key == i.get('portal', "") or \
524                        key in [e.get('element', "") \
525                        for e in i.get('member', [])]:
526                    rv = i.copy()
527                    break
528
529            else:
530                return rv
531
532            if 'fedAttr' in rv:
533                for a in rv['fedAttr']:
534                    attr = a.get('attribute', "")
535                    val = a.get('value', "")
536                    if attr and attr not in rv:
537                        rv[attr] = val
538                del rv['fedAttr']
539            return rv
540
541        # XXX: un hardcode this
542        def client_null(f, s):
543            print >>f, "Service: %s" % s['name']
544
545        def client_seer_master(f, s):
546            print >>f, 'PortalAlias: seer-master';
547
548        def client_smb(f, s):
549            print >>f, "Service: %s" % s['name']
550            smbshare = None
551            smbuser = None
552            smbproj = None
553            for a in s.get('fedAttr', []):
554                if a.get('attribute', '') == 'SMBSHARE':
555                    smbshare = a.get('value', None)
556                elif a.get('attribute', '') == 'SMBUSER':
557                    smbuser = a.get('value', None)
558                elif a.get('attribute', '') == 'SMBPROJ':
559                    smbproj = a.get('value', None)
560
561            if all((smbshare, smbuser, smbproj)):
562                print >>f, "SMBshare: %s" % smbshare
563                print >>f, "ProjectUser: %s" % smbuser
564                print >>f, "ProjectName: %s" % smbproj
565
566        def client_hide_hosts(f, s):
567            for a in s.get('fedAttr', [ ]):
568                if a.get('attribute', "") == 'hosts':
569                    print >>f, "Hide: %s" % a.get('value', "")
570
571        client_service_out = {
572                'SMB': client_smb,
573                'tmcd': client_null,
574                'seer': client_null,
575                'userconfig': client_null,
576                'project_export': client_null,
577                'seer_master': client_seer_master,
578                'hide_hosts': client_hide_hosts,
579            }
580
581        def client_seer_master_export(f, s):
582            print >>f, "AddedNode: seer-master"
583
584        def client_seer_local_export(f, s):
585            print >>f, "AddedNode: control"
586
587        client_export_service_out = {
588                'seer_master': client_seer_master_export,
589                'local_seer_control': client_seer_local_export,
590            }
591
592        def server_port(f, s):
593            p = urlparse(s.get('server', 'http://localhost'))
594            print >>f, 'port: remote:%s:%s:%s' % (p.port, p.hostname, p.port) 
595
596        def server_null(f,s): pass
597
598        def server_seer(f, s):
599            print >>f, 'seer: True'
600
601        server_service_out = {
602                'SMB': server_port,
603                'tmcd': server_port,
604                'userconfig': server_null,
605                'project_export': server_null,
606                'seer': server_seer,
607                'seer_master': server_port,
608                'hide_hosts': server_null,
609            }
610        # XXX: end un hardcode this
611
612
613        seer_out = False
614        client_out = False
615        mproj = None
616        mexp = None
617        control_gw = None
618        testbed = ""
619        # Create configuration files for the portals
620        for e in [ e for e in topo.elements \
621                if isinstance(e, topdl.Computer) and e.get_attribute('portal')]:
622            myname = e.name
623            type = e.get_attribute('portal_type')
624
625            info = conninfo_to_dict(myname, connInfo)
626
627            if not info:
628                raise service_error(service_error.req,
629                        "No connectivity info for %s" % myname)
630
631            peer = info.get('peer', "")
632            ldomain = self.domain;
633            ssh_port = info.get('ssh_port', 22)
634
635            # Collect this for the client.conf file
636            if 'masterexperiment' in info:
637                mproj, meid = info['masterexperiment'].split("/", 1)
638
639            if type in ('control', 'both'):
640                testbed = e.get_attribute('testbed')
641                control_gw = myname
642
643            active = info.get('active', 'False')
644
645            cfn = "%s/%s.gw.conf" % (tmpdir, myname.lower())
646            tunnelconfig = self.tunnel_config
647            try:
648                f = open(cfn, "w")
649                if active == 'True':
650                    print >>f, "active: True"
651                    print >>f, "ssh_port: %s" % ssh_port
652                    if type in ('control', 'both'):
653                        for s in [s for s in services \
654                                if s.get('name', "") in self.imports]:
655                            server_service_out[s['name']](f, s)
656
657                if tunnelconfig:
658                    print >>f, "tunnelip: %s" % tunnelconfig
659                print >>f, "peer: %s" % peer.lower()
660                print >>f, "ssh_pubkey: /proj/%s/exp/%s/tmp/%s" % \
661                        (lproj, leid, pubkey_base)
662                print >>f, "ssh_privkey: /proj/%s/exp/%s/tmp/%s" % \
663                        (lproj, leid, secretkey_base)
664                f.close()
665            except EnvironmentError, e:
666                raise service_error(service_error.internal,
667                        "Can't write protal config %s: %s" % (cfn, e))
668
669        # Done with portals, write the client config file.
670        try:
671            f = open("%s/client.conf" % tmpdir, "w")
672            if control_gw:
673                print >>f, "ControlGateway: %s.%s.%s%s" % \
674                    (myname.lower(), leid.lower(), lproj.lower(),
675                            ldomain.lower())
676            for s in services:
677                if s.get('name',"") in self.imports and \
678                        s.get('visibility','') == 'import':
679                    client_service_out[s['name']](f, s)
680                if s.get('name', '') in self.exports and \
681                        s.get('visibility', '') == 'export' and \
682                        s['name'] in client_export_service_out:
683                    client_export_service_out[s['name']](f, s)
684            # Seer uses this.
685            if mproj and meid:
686                print >>f, "ExperimentID: %s/%s" % (mproj, meid)
687            f.close()
688        except EnvironmentError, e:
689            raise service_error(service_error.internal,
690                    "Cannot write client.conf: %s" %s)
691
692    def import_store_info(self, cf, connInfo):
693        """
694        Pull any import parameters in connInfo in.  We translate them either
695        into known member names or fedAddrs.
696        """
697
698        for c in connInfo:
699            for p in [ p for p in c.get('parameter', []) \
700                    if p.get('type', '') == 'input']:
701                name = p.get('name', None)
702                key = p.get('key', None)
703                store = p.get('store', None)
704
705                if name and key and store :
706                    req = { 'name': key, 'wait': True }
707                    self.log.debug("Waiting for %s (%s) from %s" % \
708                            (name, key, store))
709                    r = self.call_GetValue(store, req, cf)
710                    r = r.get('GetValueResponseBody', None)
711                    if r :
712                        if r.get('name', '') == key:
713                            v = r.get('value', None)
714                            if v is not None:
715                                if name == 'peer':
716                                    self.log.debug("Got peer %s" % v)
717                                    c['peer'] = v
718                                else:
719                                    self.log.debug("Got %s %s" % (name, v))
720                                    if c.has_key('fedAttr'):
721                                        c['fedAttr'].append({
722                                            'attribute': name, 'value': v})
723                                    else:
724                                        c['fedAttr']= [{
725                                            'attribute': name, 'value': v}]
726                            else:
727                                raise service_error(service_error.internal, 
728                                        'None value exported for %s'  % key)
729                        else:
730                            raise service_error(service_error.internal, 
731                                    'Different name returned for %s: %s' \
732                                            % (key, r.get('name','')))
733                    else:
734                        raise service_error(service_error.internal, 
735                            'Badly formatted response: no GetValueResponseBody')
736                else:
737                    raise service_error(service_error.internal, 
738                        'Bad Services missing info for import %s' % c)
Note: See TracBrowser for help on using the repository browser.