From b5e017d07d3e4e51a79abbbbff0a88991cab57c7 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Chibon Date: May 18 2017 09:06:50 +0000 Subject: Start work on the mirroring hook --- diff --git a/pagure/hooks/mirror_hook.py b/pagure/hooks/mirror_hook.py new file mode 100644 index 0000000..3fbc1da --- /dev/null +++ b/pagure/hooks/mirror_hook.py @@ -0,0 +1,276 @@ +# -*- coding: utf-8 -*- + +""" + (c) 2016 - Copyright Red Hat Inc + + Authors: + Pierre-Yves Chibon + +""" + +import base64 +import os + +import sqlalchemy as sa +import pygit2 +import werkzeug +import wtforms + +from Crypto.PublicKey import RSA +from flask.ext import wtf +from sqlalchemy.orm import relation +from sqlalchemy.orm import backref + +from pagure.exceptions import PagureException +from pagure.hooks import BaseHook, RequiredIf +from pagure.lib.model import BASE, Project +from pagure import APP, get_repo_path + +CONFIG_TPL = '''host %(name)s + HostName %(host)s + User %(user)s + IdentityFile ~/.ssh/%(name)s + +''' + + +def split_target(target): + ''' Check if the given target follows the expected model. ''' + if target.startswith('http'): + raise PagureException( + 'Invalid target %s, we only support mirroring via ssh' % target) + + if target.startswith('ssh://'): + target = target.replace('ssh://', '', 1) + target = target.replace('/', ':', 1) + + if not '@' in target: + raise PagureException( + 'No user specified in %s, we were expecting it before a `@`' + % target) + if not ':' in target: + raise PagureException( + 'No path specified in %s, we were expecting it after a `:`' + % target) + user, host_path = target.split('@', 1) + host, path = host_path.split(':', 1) + return user, host, path + + +def create_ssh_key(keyfile): + ''' Create the public and private ssh keys. + + The specified file name will be the private key and the public one will + be in a similar file name ending with a '.pub'. + + ''' + key = RSA.generate(2048) + with open(keyfile, 'w') as stream: + stream.write(key.exportKey('PEM')) + + with open(keyfile + '.pub', 'w') as stream: + stream.write(key.exportKey('OpenSSH')) + + +def check_or_create_ssh_config(ssh_folder, key_name, target): + ''' Check or adjust the ~/.ssh/config file ''' + ssh_config_file = os.path.join(ssh_folder, 'config') + user, host, path = split_target(target) + + ssh_config = CONFIG_TPL % { + 'user': user, + 'host': '%s:%s' % (host, path) + 'name': key_name + } + + update = True + if os.path.exists(ssh_config_file): + with open(ssh_config_file) as stream: + data = stream.read() + if ssh_config in data: + update = False + + if update: + with open(ssh_config_file, 'a') as stream: + stream.write(ssh_config) + + +def clean_ssh_config(ssh_folder, key_name, target): + ''' Check or adjust the ~/.ssh/config file ''' + ssh_config_file = os.path.join(ssh_folder, 'config') + user, host, path = split_target(target) + + ssh_config = CONFIG_TPL % { + 'user': user, + 'host': '%s:%s' % (host, path) + 'name': key_name + } + + data = None + if os.path.exists(ssh_config_file): + with open(ssh_config_file) as stream: + data = stream.read() + if ssh_config in data: + data = data.replace(ssh_config, '', 1) + + if data: + with open(ssh_config_file, 'w') as stream: + stream.write(data) + + +class MirrorTable(BASE): + """ Stores information about the mirroring hook deployed on a project. + + Table -- mirror_pagure + """ + + __tablename__ = 'mirror_pagure' + + id = sa.Column(sa.Integer, primary_key=True) + project_id = sa.Column( + sa.Integer, + sa.ForeignKey( + 'projects.id', onupdate='CASCADE', ondelete='CASCADE'), + nullable=False, + unique=True, + index=True) + + active = sa.Column(sa.Boolean, nullable=False, default=False) + + public_key = sa.Column(sa.Text, nullable=True) + target = sa.Column(sa.Text, nullable=True) + + project = relation( + 'Project', remote_side=[Project.id], + backref=backref( + 'mirror_hook', cascade="delete, delete-orphan", + single_parent=True) + ) + + +class MirrorForm(wtf.Form): + ''' Form to configure the mirror hook. ''' + active = wtforms.BooleanField( + 'Active', + [wtforms.validators.Optional()] + ) + + target = wtforms.TextField( + 'Git repo to mirror to', + [RequiredIf('active')] + ) + + public_key = wtforms.TextField( + 'Public SSH key', + [wtforms.validators.Optional()] + ) + + +DESCRIPTION = ''' +Pagure specific hook to add a comment to issues or pull requests if the pushed +commits fix them +or relate to them. This is determined based on the commit message. + +To reference an issue/PR you need to use one of recognized keywords followed by +a reference to the issue or PR, separated by whitespace and and optional colon. +Such references can be either: + + * The issue/PR number preceded by the `#` symbol + * The full URL of the issue or PR + +If using the full URL, it is possible to reference issues in other projects. + +The recognized keywords are: + + * fix/fixed/fixes + * relate/related/relates + * merge/merges/merged + +Examples: + + * Fixes #21 + * related: https://pagure.io/myproject/issue/32 + * this commit merges #74 + * Merged: https://pagure.io/myproject/pull-request/74 + +Capitalization does not matter; neither does the colon between keyword and +number. + + +''' + + +class MirrorHook(BaseHook): + ''' Mirror hook. ''' + + name = 'Mirroring' + description = DESCRIPTION + form = MirrorForm + db_object = MirrorTable + backref = 'mirror_hook' + form_fields = ['active'] + + @classmethod + def install(cls, project, dbobj): + ''' Method called to install the hook for a project. + + :arg project: a ``pagure.model.Project`` object to which the hook + should be installed + + ''' + if not APP.config.get('GITOLITE_HOME'): + raise PagureException( + 'Gitolite wrongly configured, please contact your admin.') + + ssh_folder = os.path.join(APP.config.get('GITOLITE_HOME'), '.ssh') + if not os.path.exists(ssh_folder): + os.makedirs(ssh_folder) + + public_key_name = werkzeug.secure_filename(project.fullname) + public_key_file = os.path.join( + ssh_folder, '%s.pub' % public_key_name) + if not os.path.exist(public_key_file): + create_ssh_key(os.path.join(ssh_folder, public_key_name)) + + with open(public_key_file) as stream: + public_key = stream.read() + + check_or_create_ssh_config( + ssh_folder, public_key_name, dbobj.target) + + if dbobj.public_key != public_key: + dbobj.public_key = public_key + APP.SESSION.add(dbobj) + APP.SESSION.commit() + + repopaths = [get_repo_path(project)] + cls.base_install(repopaths, dbobj, 'mirror', 'mirror_hook.py') + + @classmethod + def remove(cls, project): + ''' Method called to remove the hook of a project. + + :arg project: a ``pagure.model.Project`` object to which the hook + should be installed + + ''' + if not APP.config.get('GITOLITE_HOME'): + raise PagureException( + 'Gitolite wrongly configured, please contact your admin.') + + ssh_folder = os.path.join(APP.config.get('GITOLITE_HOME'), '.ssh') + if not os.path.exists(ssh_folder): + os.makedirs(ssh_folder) + + public_key_name = werkzeug.secure_filename(project.fullname) + public_key_file = os.path.join( + ssh_folder, '%s.pub' % public_key_name) + + if os.path.exist(public_key_file): + os.unlink(public_key_file) + + clean_ssh_config(ssh_folder, key_name, target) + + repopaths = [get_repo_path(project)] + + cls.base_remove(repopaths, 'mirror')