jenkins-bot has submitted this change and it was merged. ( 
https://gerrit.wikimedia.org/r/364133 )

Change subject: Create new tools
......................................................................


Create new tools

Allow Tool Labs members to create new tool accounts. Tool creation
will also create a basic ToolInfo record for the new tool.

Bug: T149458
Change-Id: I1b112b7d04dcf20771c938fd67ac302f6c0b4c3f
---
M contrib/del-tool.sh
M striker/settings.py
M striker/templates/goals/TOOL_MAINTAINER.html
A striker/templates/tools/create.html
M striker/templates/tools/index.html
M striker/tools/forms.py
M striker/tools/models.py
M striker/tools/urls.py
A striker/tools/utils.py
M striker/tools/views.py
10 files changed, 427 insertions(+), 19 deletions(-)

Approvals:
  BryanDavis: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/contrib/del-tool.sh b/contrib/del-tool.sh
index cc9aead..ac86603 100755
--- a/contrib/del-tool.sh
+++ b/contrib/del-tool.sh
@@ -2,7 +2,7 @@
 # Hack a tool into the testing LDAP server setup with MediaWiki-Vagrant's
 # role::striker.
 #
-# Usage: add-tool.sh NAME [DN_OF_MAINTAINER]
+# Usage: del-tool.sh NAME [DN_OF_MAINTAINER]
 
 TOOL=${1:?TOOL required}
 BASE_DN="dc=wmftest,dc=net"
@@ -13,4 +13,10 @@
 /usr/bin/ldapadd -x -D "${ADMIN_DN}" -w "${ADMIN_PASS}" <<LDIF
 dn: cn=tools.${TOOL},${TOOL_BASE_DN}
 changetype: delete
+
+dn: uid=tools.${TOOL},ou=people,${TOOL_BASE_DN}
+changetype: delete
+
+dn: cn=runas-tools.${TOOL},ou=sudoers,cn=tools,ou=projects,${BASE_DN}
+changetype: delete
 LDIF
diff --git a/striker/settings.py b/striker/settings.py
index 1b5c312..0afdbad 100644
--- a/striker/settings.py
+++ b/striker/settings.py
@@ -384,6 +384,9 @@
 TOOLS_TOOL_BASE_DN = ini.get('ldap', 'TOOLS_TOOL_BASE_DN')
 TOOLS_TOOL_LABS_GROUP_NAME = ini.get('ldap', 'TOOLS_TOOL_LABS_GROUP_NAME')
 
+# == Project settings ==
+PROJECTS_BASE_DN = 'ou=projects,{}'.format(ini.get('ldap', 'BASE_DN'))
+
 # == OATH settings ==
 OATHMIDDLEWARE_REDIRECT = 'labsauth:oath'
 
diff --git a/striker/templates/goals/TOOL_MAINTAINER.html 
b/striker/templates/goals/TOOL_MAINTAINER.html
index d7f9f2d..d0bdd70 100644
--- a/striker/templates/goals/TOOL_MAINTAINER.html
+++ b/striker/templates/goals/TOOL_MAINTAINER.html
@@ -4,5 +4,6 @@
 {% block title %}{% trans "Create or join a tool" %}{% endblock %}
 
 {% block body %}
-<p>{% blocktrans with url='https://tools.wmflabs.org/' %}Your account is not 
associated with any tools. Visit <a href="{{ url }}">tools.wmflabs.org</a> to 
find a existing tool to join or create a new tool.{% endblocktrans %}</p>
+{% url "tools:index" as tools_url %}
+<p>{% blocktrans with url='' %}Your account is not associated with any tools. 
Visit <a href="{{ tools_url }}">the Tools page</a> to find a existing tool to 
join or create a new tool.{% endblocktrans %}</p>
 {% endblock %}
diff --git a/striker/templates/tools/create.html 
b/striker/templates/tools/create.html
new file mode 100644
index 0000000..f26cdc7
--- /dev/null
+++ b/striker/templates/tools/create.html
@@ -0,0 +1,43 @@
+{% extends "base.html" %}
+{% load bootstrap3 %}
+{% load i18n %}
+{% load staticfiles %}
+
+{% block title %}{% trans "Create Tool account" %}{% endblock %}
+
+{% block pre_content %}
+<ol class="breadcrumb">
+  <li><a href="{% url 'tools:index' %}">{% trans "Tools" %}</a></li>
+  <li class="active">{% trans "Create Tool account" %}</li>
+</ol>
+{{ block.super }}
+{% endblock %}
+
+{% block content %}
+<div class="container-fluid">
+  <div class="row">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <h3 class="panel-title">{% bootstrap_icon "wrench" %} {% trans "Create 
Tool account" %}</h3>
+      </div>
+      <div class="panel-body">
+        <p>{% blocktrans with 
help="https://wikitech.wikimedia.org/wiki/Help:Tool_Labs#What_is_a_Tool_account.3F";
 %}A Tool account is the "user" associated with a Tool on Tool Labs. The 
account allows multiple Tool Labs users to collaborate on creating and 
maintaining a tool. Maintainers may have more than one tool account, and tool 
accounts may have more than one maintainer. See <a href="{{ help }}">help on 
wikitech</a> for more information.{% endblocktrans %}</p>
+        <form method="post" action="{% url 'tools:tool_create' %}" class="form 
parsley">
+          {% csrf_token %}
+          {% bootstrap_form form %}
+          {% buttons %}
+          <input class="btn btn-success" type="submit" value="{% trans 
"Create" %}" />
+          {% endbuttons %}
+        </form>
+      </div>
+    </div>
+  </div>
+</div>
+{% endblock %}
+
+{% block js %}
+{{ block.super }}
+<script lang="javascript" src="{% static 'js/parsley.min.js' %}"></script>
+<script lang="javascript" src="{% static 'js/parsley-bootstrap.js' 
%}"></script>
+{{ form.media }}
+{% endblock %}
diff --git a/striker/templates/tools/index.html 
b/striker/templates/tools/index.html
index 10cfb5d..a44db7d 100644
--- a/striker/templates/tools/index.html
+++ b/striker/templates/tools/index.html
@@ -11,11 +11,14 @@
         <div class="panel-heading">{% bootstrap_icon "star" %} {% trans "Your 
tools" %}</div>
         {% if not user.is_anonymous %}
         <div class="list-group">
+          {% if member %}
+          <a class="list-group-item" href="{% url 'tools:tool_create' %}">{% 
bootstrap_icon "plus-sign" %} {% trans "Create Tool account" %}</a>
+          {% endif %}
           {% for tool in my_tools %}
           <a href="{% url 'tools:tool' tool=tool %}" 
class="list-group-item">{{ tool.name }}</a>
           {% empty %}
           {% if member %}
-          <div class="list-group-item">{% blocktrans with 
url='https://tools.wmflabs.org/' %}Your account is not associated with any 
tools. Visit <a href="{{ url }}">tools.wmflabs.org</a> to find a existing tool 
to join or create a new tool.{% endblocktrans %}</div>
+          <div class="list-group-item">{% blocktrans %}Your account is not 
associated with any tools.{% endblocktrans %}</div>
           {% else %}
           <div class="list-group-item">
             <p>{% blocktrans %}Request membership in the Tool Labs project so 
you can create your own tool and help maintain existing tools.{% endblocktrans 
%}</p>
diff --git a/striker/tools/forms.py b/striker/tools/forms.py
index 47a821f..89bfe8b 100644
--- a/striker/tools/forms.py
+++ b/striker/tools/forms.py
@@ -21,15 +21,19 @@
 import re
 
 from django import forms
+from django.core import validators
+from django.utils.safestring import mark_safe
 from django.utils.translation import ugettext_lazy as _
 
 from dal import autocomplete
 from parsley.decorators import parsleyfy
 
 from striker import phabricator
+from striker.tools import utils
 from striker.tools.models import AccessRequest
 from striker.tools.models import SoftwareLicense
 from striker.tools.models import ToolInfo
+from striker.tools.models import ToolInfoTag
 
 
 phab = phabricator.Client.default_client()
@@ -194,3 +198,95 @@
 class ToolInfoPublicForm(ToolInfoForm):
     class Meta(ToolInfoForm.Meta):
         exclude = ('name', 'tool', 'license', 'authors', 'is_webservice')
+
+
+@parsleyfy
+class ToolCreateForm(forms.Form):
+    # Unix username regex suggested by useradd(8).
+    # We don't allow a leading '_' or trailing '$' however.
+    RE_NAME = r'^[a-z][a-z0-9_-]{0,31}$'
+    NAME_ERR_MSG = _(
+        'Must start with a-z, and can only contain '
+        'lowercase a-z, 0-9, _, and - characters.'
+    )
+
+    name = forms.CharField(
+        label=_('Unique tool name'),
+        help_text=_(
+            "The tool name is used as part of the URL for the tool's "
+            "webservice."
+        ),
+        widget=forms.TextInput(
+            attrs={
+                'autofocus': 'autofocus',
+                'placeholder': _('A unique name for your tool'),
+                # Parsley gets confused if {value} is url encoded, so wrap in
+                # mark_safe().
+                # FIXME: I tried everything I could think of to use
+                # urlresolvers.reverse_lazy and I just couldn't get it to work
+                # with mark_safe(). I would get either the URL encoded
+                # property value or the __str__ of a wrapper object.
+                'data-parsley-remote': mark_safe(
+                    '/tools/api/toolname/{value}'),
+                'data-parsley-trigger': 'focusin focusout input',
+                'data-parsley-remote-message': _(
+                    'Tool name is already in use or invalid.'),
+                'data-parsley-pattern': RE_NAME,
+                'data-parsley-pattern-message': NAME_ERR_MSG,
+                'data-parsley-debounce': '500',
+            }
+        ),
+        max_length=32,
+        validators=[
+            validators.RegexValidator(regex=RE_NAME, message=NAME_ERR_MSG),
+        ]
+    )
+    title = forms.CharField(
+        label=_('Title'),
+        widget=forms.TextInput(
+            attrs={
+                'placeholder': _('A descriptive title for your tool'),
+            },
+        ),
+    )
+    description = forms.CharField(
+        label=_('Description of tool'),
+        widget=forms.Textarea(
+            attrs={
+                'placeholder': _(
+                    'A short summary of what your tool will do'
+                ),
+                'rows': 5,
+            },
+        ),
+    )
+    license = forms.ModelChoiceField(
+        queryset=SoftwareLicense.objects.filter(
+            osi_approved=True).order_by('-recommended', 'slug'),
+        empty_label=_('-- Choose your software license --'),
+        label=_('Default software license'),
+        help_text=_(
+            'Need help choosing a license? '
+            'Try <a href="{choose_a_license}">choosealicense.com</a>.'
+        ).format(choose_a_license='https://choosealicense.com/'),
+    )
+    tags = forms.ModelMultipleChoiceField(
+        queryset=ToolInfoTag.objects.all().order_by('name'),
+        widget=autocomplete.ModelSelect2Multiple(
+            url='tools:tags_autocomplete',
+        ),
+        required=False,
+    )
+
+    def clean_name(self):
+        """Validate that name is available."""
+        name = self.cleaned_data['name']
+        if not utils.toolname_available(name):
+            raise forms.ValidationError(_('Tool name is already in use.'))
+
+        # Check that it isn't banned by some abusefilter type rule
+        check = utils.check_toolname_create(name)
+        if check['ok'] is False:
+            raise forms.ValidationError(check['error'])
+
+        return name
diff --git a/striker/tools/models.py b/striker/tools/models.py
index 59c1204..d656449 100644
--- a/striker/tools/models.py
+++ b/striker/tools/models.py
@@ -36,7 +36,7 @@
     base_dn = settings.TOOLS_MAINTAINER_BASE_DN
     object_classes = ['posixAccount']
 
-    username = fields.CharField(db_column='uid', unique=True)
+    username = fields.CharField(db_column='uid', primary_key=True)
     full_name = fields.CharField(db_column='cn')
 
     def __str__(self):
@@ -56,7 +56,7 @@
 
     objects = ToolManager()
 
-    cn = fields.CharField(db_column='cn', max_length=200, unique=True)
+    cn = fields.CharField(db_column='cn', max_length=200, primary_key=True)
     gid_number = fields.IntegerField(db_column='gidNumber', unique=True)
     members = fields.ListField(db_column='member')
 
@@ -88,6 +88,44 @@
         return self.name
 
 
+class ToolUser(ldapdb.models.Model):
+    """Posix account that a tool runs as."""
+    base_dn = 'ou=people,{}'.format(settings.TOOLS_TOOL_BASE_DN)
+    object_classes = [
+        'shadowAccount',
+        'posixAccount',
+        'person',
+        'top'
+    ]
+
+    uid = fields.CharField(db_column='uid', primary_key=True)
+    cn = fields.CharField(db_column='cn', unique=True)
+    sn = fields.CharField(db_column='sn', unique=True)
+    uid_number = fields.IntegerField(db_column='uidNumber', unique=True)
+    gid_number = fields.IntegerField(db_column='gidNumber')
+    home_directory = fields.CharField(
+        db_column='homeDirectory', max_length=200)
+    login_shell = fields.CharField(db_column='loginShell', max_length=64)
+
+    def __str__(self):
+        return 'uid=%s,%s' % (self.uid, self.base_dn)
+
+
+class SudoRole(ldapdb.models.Model):
+    base_dn = 'ou=sudoers,cn=tools,{}'.format(settings.PROJECTS_BASE_DN)
+    object_classes = ['sudoRole']
+
+    cn = fields.CharField(db_column='cn', primary_key=True)
+    users = fields.ListField(db_column='sudoUser')
+    hosts = fields.ListField(db_column='sudoHost')
+    commands = fields.ListField(db_column='sudoCommand')
+    options = fields.ListField(db_column='sudoOption')
+    runas_users = fields.ListField(db_column='sudoRunAsUser')
+
+    def __str__(self):
+        return 'cn=%s,%s' % (self.cn, self.base_dn)
+
+
 class DiffusionRepo(models.Model):
     """Associate diffusion repos with Tools."""
     tool = models.CharField(max_length=64)
diff --git a/striker/tools/urls.py b/striker/tools/urls.py
index ceadb76..f25b5e4 100644
--- a/striker/tools/urls.py
+++ b/striker/tools/urls.py
@@ -117,4 +117,18 @@
         'striker.tools.views.toolinfo',
         name='toolinfo'
     ),
+    urls.url(
+        r'create/$',
+        'striker.tools.views.tool_create',
+        name='tool_create'
+    ),
+    urls.url(r'^api/', urls.include(
+        urls.patterns(
+            'striker.tools.views',
+            urls.url(
+                r'^toolname/(?P<name>.+)$',
+                'toolname_available', name='toolname'),
+        ),
+        namespace='api'
+    )),
 ]
diff --git a/striker/tools/utils.py b/striker/tools/utils.py
new file mode 100644
index 0000000..0e99450
--- /dev/null
+++ b/striker/tools/utils.py
@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2017 Wikimedia Foundation and contributors.
+# All Rights Reserved.
+#
+# This file is part of Striker.
+#
+# Striker is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Striker is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Striker.  If not, see <http://www.gnu.org/licenses/>.
+
+import logging
+
+from django.contrib.auth.models import Group
+
+from striker.labsauth.utils import get_next_gid
+from striker.register import utils as reg_utils
+from striker.tools.models import SudoRole
+from striker.tools.models import Tool
+from striker.tools.models import ToolUser
+
+
+logger = logging.getLogger(__name__)
+
+
+def toolname_available(name):
+    toolname = 'tools.{0!s}'.format(name)
+    try:
+        Tool.objects.get(cn=toolname)
+    except Tool.DoesNotExist:
+        return True
+    else:
+        return False
+
+
+def check_toolname_create(name):
+    """Check to see if a given name would be allowed as a tool name.
+
+    Returns True if the username would be allowed. Returns either False or a
+    reason specifier if the username is not allowed.
+    Returns a dict with these keys:
+    - ok : Can a new user be created with this name (True/False)
+    - name : Canonicalized version of the given name
+    - error : Error message if ok is False; None otherwise
+    """
+    return reg_utils.check_username_create(name)
+
+
+def create_tool(name, user):
+    """Create a new tool account."""
+    group_name = 'tools.{}'.format(name)
+    gid = get_next_gid()
+
+    # Create group
+    tool = Tool(cn=group_name, gid_number=gid, members=[user.ldap_dn])
+    tool.save()
+
+    # Create user that tool runs as
+    service_user = ToolUser(
+        uid=group_name,
+        sn=group_name,
+        cn=group_name,
+        uid_number=gid,
+        gid_number=gid,
+        home_directory='/data/project/{}'.format(name),
+    )
+    service_user.save()
+
+    # Create sudoers rule to allow maintainers to act as tool user
+    sudoers = SudoRole(
+        cn='runas-{}'.format(group_name),
+        users=['%{}'.format(group_name)],
+        hosts=['ALL'],
+        commands=['ALL'],
+        options=['!authenticate'],
+        runas_users=[group_name],
+    )
+    sudoers.save()
+
+    # Mirror the tool as a Django Group so we can send notifications.
+    # This is normally done on user login by labsauth.
+    try:
+        maintainers, created = Group.objects.get_or_create(name=tool.cn)
+        user.groups.add(maintainers.id)
+    except Exception:
+        logger.exception(
+            'Failed to add %s to django maintainers group', user)
+
+    return tool
diff --git a/striker/tools/views.py b/striker/tools/views.py
index 89f2295..86dc7f9 100644
--- a/striker/tools/views.py
+++ b/striker/tools/views.py
@@ -29,6 +29,7 @@
 from django.core import paginator
 from django.core import urlresolvers
 from django.core.exceptions import ObjectDoesNotExist
+from django.core.exceptions import PermissionDenied
 from django.core.serializers.json import DjangoJSONEncoder
 from django.db import transaction
 from django.db.utils import DatabaseError
@@ -37,6 +38,7 @@
 from django.utils import timezone
 from django.utils.text import slugify
 from django.utils.translation import ugettext_lazy as _
+from django.views.decorators.cache import never_cache
 
 from dal import autocomplete
 from notifications.signals import notify
@@ -47,9 +49,11 @@
 from striker import mediawiki
 from striker import openstack
 from striker import phabricator
+from striker.tools import utils
 from striker.tools.forms import AccessRequestAdminForm
 from striker.tools.forms import AccessRequestForm
 from striker.tools.forms import RepoCreateForm
+from striker.tools.forms import ToolCreateForm
 from striker.tools.forms import ToolInfoForm
 from striker.tools.forms import ToolInfoPublicForm
 from striker.tools.models import AccessRequest
@@ -110,6 +114,16 @@
 def project_member(user):
     groups = user.groups.values_list('name', flat=True)
     return settings.TOOLS_TOOL_LABS_GROUP_NAME in groups
+
+
+def require_tools_member(f):
+    """Ensure that the active user is a member of the tools project."""
+    @functools.wraps(f)
+    def decorated(request, *args, **kwargs):
+        if project_member(request.user):
+            return f(request, *args, **kwargs)
+        raise PermissionDenied
+    return decorated
 
 
 def index(req):
@@ -426,20 +440,26 @@
                     created_by=req.user)
                 try:
                     repo_model.save()
-                    notify.send(
-                        recipient=Group.objects.get(name=tool.cn),
-                        sender=req.user,
-                        verb=_('created'),
-                        target=repo_model,
-                        public=True,
-                        level='info',
-                        actions=[
-                            {
-                                'title': _('View repository'),
-                                'href': repo_model.get_absolute_url(),
-                            },
-                        ],
-                    )
+                    try:
+                        maintainers = Group.objects.get(name=tool.cn)
+                    except ObjectDoesNotExist:
+                        # Can't find group for the tool
+                        pass
+                    else:
+                        notify.send(
+                            recipient=Group.objects.get(name=tool.cn),
+                            sender=req.user,
+                            verb=_('created new repo'),
+                            target=repo_model,
+                            public=True,
+                            level='info',
+                            actions=[
+                                {
+                                    'title': _('View repository'),
+                                    'href': repo_model.get_absolute_url(),
+                                },
+                            ],
+                        )
                     # Redirect to repo view
                     return shortcuts.redirect(
                         urlresolvers.reverse('tools:repo_view', kwargs={
@@ -706,3 +726,89 @@
         encoder=PrettyPrintJSONEncoder,
         safe=False,
     )
+
+
+@require_tools_member
+@login_required
+def tool_create(req):
+    form = ToolCreateForm(req.POST or None, req.FILES or None)
+    if req.method == 'POST':
+        if form.is_valid():
+            try:
+                tool = utils.create_tool(form.cleaned_data['name'], req.user)
+            except Exception:
+                logger.exception('utils.create_tool failed')
+                messages.error(
+                    req,
+                    _("Error creating tool. [req id: {id}]").format(
+                        id=req.id))
+            else:
+                messages.info(
+                    req, _("Tool {} created".format(tool.name)))
+                try:
+                    with reversion.create_revision():
+                        toolinfo = ToolInfo(
+                            name='toolforge.{}'.format(
+                                form.cleaned_data['name']),
+                            tool=form.cleaned_data['name'],
+                            title=form.cleaned_data['title'],
+                            description=form.cleaned_data['description'],
+                            license=form.cleaned_data['license'],
+                            is_webservice=False,
+                        )
+                        reversion.set_user(req.user)
+                        reversion.set_comment('Tool created')
+                        toolinfo.save()
+                        toolinfo.authors.add(req.user)
+                        if form.cleaned_data['tags']:
+                            toolinfo.tags.add(*form.cleaned_data['tags'])
+                except Exception:
+                    logger.exception('ToolInfo create failed')
+                    messages.error(
+                        req,
+                        _("Error creating toolinfo. [req id: {id}]").format(
+                            id=req.id))
+                try:
+                    maintainers = Group.objects.get(name=tool.cn)
+                except ObjectDoesNotExist:
+                    # Can't find group for the tool
+                    pass
+                else:
+                    # Do not set tool as the notification target because the
+                    # framework does not understand LDAP models.
+                    notify.send(
+                        recipient=maintainers,
+                        sender=req.user,
+                        verb=_('created tool {}').format(tool.name),
+                        public=True,
+                        level='info',
+                        actions=[
+                            {
+                                'title': _('View tool'),
+                                'href': tool.get_absolute_url(),
+                            },
+                        ],
+                    )
+
+                return shortcuts.redirect(tool.get_absolute_url())
+    ctx = {
+        'form': form,
+    }
+    return shortcuts.render(req, 'tools/create.html', ctx)
+
+
+@never_cache
+def toolname_available(req, name):
+    """JSON callback for parsley validation of tool name.
+
+    Kind of gross, but it returns a 406 status code when the name is not
+    available. This is to work with the limited choice of default response
+    validators in parsley.
+    """
+    available = utils.toolname_available(name)
+    if available:
+        available = utils.check_toolname_create(name)['ok']
+    status = 200 if available else 406
+    return JsonResponse({
+        'available': available,
+    }, status=status)

-- 
To view, visit https://gerrit.wikimedia.org/r/364133
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I1b112b7d04dcf20771c938fd67ac302f6c0b4c3f
Gerrit-PatchSet: 1
Gerrit-Project: labs/striker
Gerrit-Branch: master
Gerrit-Owner: BryanDavis <bda...@wikimedia.org>
Gerrit-Reviewer: Andrew Bogott <abog...@wikimedia.org>
Gerrit-Reviewer: BryanDavis <bda...@wikimedia.org>
Gerrit-Reviewer: Madhuvishy <mviswanat...@wikimedia.org>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to