source: repositorymanagerplugin/repo_mgr/api.py @ 72:64c783e9086b

Last change on this file since 72:64c783e9086b was 72:64c783e9086b, checked in by Tobias Föhst <foehst@…>, 5 years ago

Various smaller corrections

File size: 21.2 KB
Line 
1from trac.core import *
2from trac.versioncontrol.api import RepositoryManager as TracRepositoryManager
3from trac.versioncontrol.svn_authz import AuthzSourcePolicy
4from trac.perm import PermissionSystem
5from trac.util import as_bool
6from trac.util.translation import _
7from trac.config import Option, BoolOption
8
9from ConfigParser import ConfigParser
10
11import os
12import errno
13import stat
14import shutil
15
16class IAdministrativeRepositoryConnector(Interface):
17    """Provide support for a specific version control system.
18
19    Instead of Trac's usual IRepositoyConnector interface for navigating
20    repositories of specific version control systems, this one is used
21    for more administrative tasks like creation, forking, renaming or
22    deleting repositories.
23    """
24
25    error = None
26
27    def get_supported_types():
28        """Return the supported types of version control systems.
29
30        Yields `(repository type, priority)` pairs.
31
32        If multiple provider match a given type, the `priority` is used
33        to choose between them (highest number is highest priority).
34
35        If the `priority` returned is negative, this indicates that the
36        connector for the given `repository type` indeed exists but can
37        not be used for some reason. The `error` property can then be
38        used to store an error message or exception relevant to the
39        problem detected.
40        """
41
42    def can_fork(repository_type):
43        """Return whether forking is supported by the connector."""
44
45    def create(repository):
46        """Create a new empty repository with given attributes."""
47
48    def fork(repository):
49        """Fork from `origin_url` in the given dict."""
50
51    def update_auth_files(repositories):
52        """Write auth information to e.g. authz for .hgrc files"""
53
54class RepositoryManager(Component):
55    """Adds creation, modification and deletion of repositories.
56
57    This class extends Trac's `RepositoryManager` and adds some
58    capabilities that allow users to create and manage repositories.
59    The original `RepositoryManager` *just* allows adding and removing
60    existing repositories from Trac's database, which means that still
61    someone must do some shell work on the server.
62
63    To work nicely together with manually created and added repositories
64    a new `ManagedRepository` class is used to mark the ones that can be
65    handled by this module. It also implements forking, if the connector
66    supports that, which creates instances of `ForkedRepository`.
67    """
68
69    base_dir = Option('repository-manager', 'base_dir', 'repositories',
70                      doc="""The base folder in which repositories will be
71                             created.
72                             """)
73    owner_as_maintainer = BoolOption('repository-manager',
74                                     'owner_as_maintainer',
75                                     True,
76                                     doc="""If true, the owner will have the
77                                            role of a maintainer, too.
78                                            Otherwise, he will only act as an
79                                            administrator for his repositories.
80                                            """)
81
82    connectors = ExtensionPoint(IAdministrativeRepositoryConnector)
83
84    manager = None
85
86    roles = ('maintainer', 'writer', 'reader')
87
88    def __init__(self):
89        self.manager = TracRepositoryManager(self.env)
90
91    def get_supported_types(self):
92        """Return the list of supported repository types."""
93        types = set(type for connector in self.connectors
94                    for (type, prio) in connector.get_supported_types() or []
95                    if prio >= 0)
96        return list(types & set(self.manager.get_supported_types()))
97
98    def get_forkable_types(self):
99        """Return the list of forkable repository types."""
100        return list(type for type in self.get_supported_types()
101                    if self.can_fork(type))
102
103    def can_fork(self, type):
104        """Return whether the given repository type can be forked."""
105        return self._get_repository_connector(type).can_fork(type)
106
107    def get_forkable_repositories(self):
108        """Return a dictionary of repository information, indexed by
109        name and including only repositories that can be forked."""
110        repositories = self.manager.get_all_repositories()
111        result = {}
112        for key in repositories:
113            if repositories[key]['type'] in self.get_forkable_types():
114                result[key] = repositories[key]['name']
115        return result
116
117    def get_managed_repositories(self):
118        """Return the list of existing managed repositories."""
119        repositories = self.manager.get_all_repositories()
120        result = {}
121        for key in repositories:
122            try:
123                self.get_repository(repositories[key]['name'], True)
124                result[key] = repositories[key]['name']
125            except:
126                pass
127        return result
128
129    def get_repository(self, name, convert_to_managed=False):
130        """Retrieve the appropriate repository for the given name.
131
132        Converts the found repository into a `ManagedRepository`, if
133        requested. In that case, expect an exception if the found
134        repository was not created using this `RepositoryManager`.
135        """
136        repo = self.manager.get_repository(name)
137        if repo and convert_to_managed:
138            convert_managed_repository(self.env, repo)
139        return repo
140
141    def get_repository_by_id(self, id, convert_to_managed=False):
142        """Retrieve a matching `Repository` for the given id."""
143        repositories = self.manager.get_all_repositories()
144        for name, info in repositories.iteritems():
145            if info['id'] == int(id):
146                return self.get_repository(name, convert_to_managed)
147        return None
148
149    def get_repository_by_path(self, path):
150        """Retrieve a matching `Repository` for the given path."""
151        return self.manager.get_repository_by_path(path)
152
153    def get_base_directory(self, type):
154        """Get the base directory for the given repository type."""
155        return os.path.join(self.env.path, self.base_dir, type)
156
157    def create(self, repo):
158        """Create a new empty repository.
159
160         * Checks if the new repository can be created and added
161         * Prepares the filesystem
162         * Uses an appropriate connector to create and initialize the
163           repository
164         * Postprocesses the filesystem (modes)
165         * Inserts everything into the database and synchronizes Trac
166        """
167        if self.get_repository(repo['name']) or os.path.lexists(repo['dir']):
168            raise TracError(_("Repository or directory already exists."))
169
170        self._prepare_base_directory(repo['dir'])
171
172        self._get_repository_connector(repo['type']).create(repo)
173
174        self._adjust_modes(repo['dir'])
175
176        with self.env.db_transaction as db:
177            id = self.manager.get_repository_id(repo['name'])
178            roles = list((id, role + 's', '') for role in self.roles)
179            db.executemany(
180                "INSERT INTO repository (id, name, value) VALUES (%s, %s, %s)",
181                [(id, 'dir', repo['dir']),
182                 (id, 'type', repo['type']),
183                 (id, 'owner', repo['owner'])] + roles)
184            self.manager.reload_repositories()
185        self.manager.get_repository(repo['name']).sync(None, True)
186        self.update_auth_files()
187
188    def fork_local(self, repo):
189        """Fork a local repository.
190
191         * Checks if the new repository can be created and added
192         * Checks if the origin exists and can be forked
193         * The filesystem is obviously already prepared
194         * Uses an appropriate connector to fork the repository
195         * Postprocesses the filesystem (modes)
196         * Inserts everything into the database and synchronizes Trac
197        """
198        if self.get_repository(repo['name']) or os.path.lexists(repo['dir']):
199            raise TracError(_("Repository or directory already exists."))
200
201        origin = self.get_repository(repo['origin'], True)
202        if not origin:
203            raise TracError(_("Origin for local fork does not exist."))
204        if origin.type != repo['type']:
205            raise TracError(_("Fork of local repository must have same type "
206                              "as origin."))
207        repo.update({'origin_url': 'file://' + origin.directory})
208
209        self._prepare_base_directory(repo['dir'])
210
211        self._get_repository_connector(repo['type']).fork(repo)
212
213        self._adjust_modes(repo['dir'])
214
215        with self.env.db_transaction as db:
216            id = self.manager.get_repository_id(repo['name'])
217            roles = list((id, role + 's', '') for role in self.roles)
218            db.executemany(
219                "INSERT INTO repository (id, name, value) VALUES (%s, %s, %s)",
220                [(id, 'dir', repo['dir']),
221                 (id, 'type', repo['type']),
222                 (id, 'owner', repo['owner']),
223                 (id, 'description', origin.description),
224                 (id, 'origin', origin.id),
225                 (id, 'inherit_readers', True)] + roles)
226            self.manager.reload_repositories()
227        self.manager.get_repository(repo['name']).sync(None, True)
228        self.update_auth_files()
229
230    def modify(self, repo, data):
231        """Modify an existing repository."""
232        convert_managed_repository(self.env, repo)
233        if repo.directory != data['dir']:
234            shutil.move(repo.directory, data['dir'])
235        with self.env.db_transaction as db:
236            db.executemany(
237                "UPDATE repository SET value = %s WHERE id = %s AND name = %s",
238                [(data[key], repo.id, key) for key in data])
239            self.manager.reload_repositories()
240        if repo.directory != data['dir']:
241            repo = self.get_repository(data['name'])
242            repo.sync(clean=True)
243        self.update_auth_files()
244
245    def remove(self, repo, delete):
246        """Remove an existing repository.
247
248        Depending on the parameter delete this method also removes the
249        repository from the filesystem. This can not be undone.
250        """
251        convert_managed_repository(self.env, repo)
252        if delete:
253            shutil.rmtree(repo.directory)
254        with self.env.db_transaction as db:
255            db("DELETE FROM repository WHERE id = %d" % repo.id)
256            db("DELETE FROM revision WHERE repos = %d" % repo.id)
257            db("DELETE FROM node_change WHERE repos = %d" % repo.id)
258        self.manager.reload_repositories()
259        self.update_auth_files()
260
261    def add_role(self, repo, role, subject):
262        """Add a role for the given repository."""
263        assert role in self.roles
264        convert_managed_repository(self.env, repo)
265        role_attr = '_' + role + 's'
266        setattr(repo, role_attr,
267                getattr(repo, role_attr) | set([subject]))
268        self._update_roles_in_db(repo)
269
270    def revoke_roles(self, repo, roles):
271        """Revoke a list of `role, subject` pairs."""
272        convert_managed_repository(self.env, repo)
273        for role, subject in roles:
274            role_attr = '_' + role + 's'
275            config = getattr(repo, role_attr)
276            config = config - set([subject])
277            setattr(repo, role_attr,
278                    getattr(repo, role_attr) - set([subject]))
279        self._update_roles_in_db(repo)
280
281    def update_auth_files(self):
282        """Rewrites all configured auth files for all managed
283        repositories.
284        """
285        types = self.get_supported_types()
286        all_repositories = []
287        for repo in self.manager.get_real_repositories():
288            try:
289                convert_managed_repository(self.env, repo)
290                all_repositories.append(repo)
291            except:
292                pass
293        for type in types:
294            repos = [repo for repo in all_repositories if repo.type == type]
295            self._get_repository_connector(type).update_auth_files(repos)
296
297        authz_source_file = AuthzSourcePolicy(self.env).authz_file
298        if authz_source_file:
299            authz_source_path = os.path.join(self.env.path, authz_source_file)
300
301            authz = ConfigParser()
302
303            groups = set()
304            for repo in all_repositories:
305                groups |= {name for name in repo.maintainers() if name[0] == '@'}
306                groups |= {name for name in repo.writers() if name[0] == '@'}
307                groups |= {name for name in repo.readers() if name[0] == '@'}
308
309            authz.add_section('groups')
310            for group in groups:
311                members = expand_user_set(self.env, [group])
312                authz.set('groups', group[1:], ', '.join(sorted(members)))
313            authenticated = sorted({u[0] for u in self.env.get_known_users()})
314            authz.set('groups', 'authenticated', ', '.join(authenticated))
315
316            for repo in all_repositories:
317                section = repo.reponame + ':/'
318                authz.add_section(section)
319                r = repo.maintainers() | repo.writers() | repo.readers()
320
321                def apply_user_list(users, action):
322                    if not users:
323                        return
324                    if 'anonymous' in users:
325                        authz.set(section, '*', action)
326                        return
327                    if 'authenticated' in users:
328                        authz.set(section, '@authenticated', action)
329                        return
330                    for user in sorted(users):
331                        authz.set(section, user, action)
332
333                apply_user_list(r, 'r')
334
335            self._prepare_base_directory(authz_source_path)
336            with open(authz_source_path, 'wb') as authz_file:
337                authz.write(authz_file)
338            try:
339                modes = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP
340                os.chmod(authz_source_path, modes)
341            except:
342                pass
343
344    ### Private methods
345    def _get_repository_connector(self, repo_type):
346        """Get the matching connector with maximum priority."""
347        return max(((connector, type, prio) for connector in self.connectors
348                    for (type, prio) in connector.get_supported_types()
349                    if prio >= 0 and type == repo_type),
350                   key=lambda x: x[2])[0]
351
352    def _prepare_base_directory(self, directory):
353        """Create the base directories and set the correct modes."""
354        base = os.path.dirname(directory)
355        original_umask = os.umask(0)
356        try:
357            os.makedirs(base, stat.S_IRWXU | stat.S_IRWXG)
358        except OSError, e:
359            if e.errno == errno.EEXIST and os.path.isdir(base):
360                pass
361            else:
362                raise
363        finally:
364            os.umask(original_umask)
365
366    def _adjust_modes(self, directory):
367        """Set modes 770 and 660 for directories and files."""
368        try:
369            os.chmod(directory, stat.S_IRWXU | stat.S_IRWXG)
370            fmodes = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP
371            dmodes = stat.S_IRWXU | stat.S_IRWXG
372            for subdir, dirnames, filenames in os.walk(directory):
373                for dirname in dirnames:
374                    os.chmod(os.path.join(subdir, dirname), dmodes)
375                    for filename in filenames:
376                        os.chmod(os.path.join(subdir, filename), fmodes)
377        except OSError, e:
378            raise TracError(_("Failed to adjust file modes: " + str(e)))
379
380    def _update_roles_in_db(self, repo):
381        """Make the current roles persistent in the database."""
382        roles = {}
383        for role in self.roles:
384            roles[role] = getattr(repo, '_' + role + 's')
385        with self.env.db_transaction as db:
386            db.executemany(
387                "UPDATE repository SET value = %s WHERE id = %s AND name = %s",
388                [(','.join(roles[role]), repo.id, role + 's')
389                 for role in self.roles])
390
391def convert_managed_repository(env, repo):
392    """Convert a given repository into a `ManagedRepository`."""
393
394    class ManagedRepository(repo.__class__):
395        """A repository managed by the new `RepositoryManager`.
396
397        This repository class inherits from the original class of the
398        given repository and adds fields needed by the manager.
399
400        Trying to convert a repository that was added via the original
401        `RepositoryAdminPanel` raises an exception and can therefore
402        be used to easily check if we are working with a manageable
403        repository.
404        """
405
406        id = None
407        owner = None
408        type = None
409        description = None
410        is_fork = False
411        is_forkable = False
412        directory = None
413        _owner_is_maintainer = False
414        _maintainers = set()
415        _writers = set()
416        _readers = set()
417
418        def maintainers(self):
419            if self._owner_is_maintainer:
420                return self._maintainers | set([self.owner])
421            return self._maintainers
422
423        def writers(self):
424            return self._writers | set([self.owner])
425
426        def readers(self):
427            return self._readers | set([self.owner])
428
429    class ForkedRepository(ManagedRepository):
430        """A local fork of a `ManagedRepository`.
431
432        This repository class inherits from the original class of the
433        given repository and adds fields and methods needed by the
434        manager and for e.g. pull requests.
435        """
436
437        origin = None
438        inherit_readers = False
439
440        def get_youngest_common_ancestor(self, rev):
441            """Goes back in the repositories history starting from
442            `rev` until it finds a revision that also exists in the
443            origin of this fork.
444            """
445            nodes = [rev]
446            while len(nodes):
447                node = nodes.pop(0)
448
449                try:
450                    self.origin.get_changeset(node)
451                except:
452                    pass
453                else:
454                    return node
455
456                for ancestor in self.parent_revs(node):
457                    nodes.append(ancestor)
458
459            return None
460
461        def readers(self):
462            readers = ManagedRepository.readers(self)
463            if self.inherit_readers:
464                return readers | self.origin.maintainers()
465            return readers
466
467    def _get_role(db, role):
468        """Get the set of users that have the given `role` on this
469        repository.
470        """
471        result = db("""SELECT value FROM repository
472                       WHERE name = '%s' AND id = %d
473                       """ % (role + 's', repo.id))[0][0]
474        if result:
475            return set(result.split(','))
476        return set()
477
478    if repo.__class__ is not ManagedRepository:
479        trac_rm = TracRepositoryManager(env)
480        repo.id = trac_rm.get_repository_id(repo.reponame)
481        rm = RepositoryManager(env)
482        with env.db_transaction as db:
483            result = db("""SELECT value FROM repository
484                           WHERE name = 'owner' AND id = %d
485                           """ % repo.id)
486            if not result:
487                raise TracError(_("Not a managed repository"))
488
489            repo.__class__ = ManagedRepository
490            repo.owner = result[0][0]
491            for role in rm.roles:
492                role_attr = '_' + role + 's'
493                setattr(repo, role_attr,
494                        getattr(repo, role_attr) | _get_role(db, role))
495        repo._owner_is_maintainer = rm.owner_as_maintainer
496
497        info = trac_rm.get_all_repositories().get(repo.reponame)
498        repo.type = info['type']
499        repo.description = info.get('description')
500        repo.is_forkable = repo.type in rm .get_forkable_types()
501        repo.directory = info['dir']
502
503        with env.db_transaction as db:
504            result = db("""SELECT value FROM repository
505                           WHERE name = 'name' AND
506                                 id = (SELECT value FROM repository
507                                       WHERE name = 'origin' AND id = %d)
508                           """ % repo.id)
509            if not result:
510                return
511
512            repo.__class__ = ForkedRepository
513            repo.is_fork = True
514            repo.origin = rm.get_repository(result[0][0], True)
515            if repo.origin is None:
516                raise TracError(_("Origin of previously forked repository "
517                                  "does not exist anymore"))
518            result = db("""SELECT value FROM repository
519                           WHERE name = 'inherit_readers' AND id = %d
520                           """ % repo.id)
521            repo.inherit_readers = as_bool(result[0][0])
522
523def expand_user_set(env, users):
524    """Replaces all groups by their users until only users are left."""
525    all_permissions = PermissionSystem(env).get_all_permissions()
526
527    special_users = set(['anonymous', 'authenticated'])
528    known_users = {u[0] for u in env.get_known_users()} | special_users
529    valid_users = {perm[0] for perm in all_permissions} & known_users
530
531    groups = set()
532    user_list = list(users)
533    for name in user_list:
534        if name[0] == '@':
535            groups |= set([name])
536            for perm in (perm for perm in all_permissions
537                         if perm[1] == name[1:]):
538                if perm[0] in valid_users:
539                    user_list.append(perm[0])
540                elif not perm[0] in groups:
541                    user_list.append('@' + perm[0])
542    return set(user_list) - groups
Note: See TracBrowser for help on using the repository browser.