source: repositorymanagerplugin/repo_mgr/api.py @ 73:06bfa9e5819c

tip
Last change on this file since 73:06bfa9e5819c was 73:06bfa9e5819c, checked in by Tobias Föhst <foehst@…>, 7 years ago

Adds support for deleting and banning changesets from managed repositories

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