BryanDavis has uploaded a new change for review. (
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(-)
git pull ssh://gerrit.wikimedia.org:29418/labs/striker
refs/changes/33/364133/1
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: newchange
Gerrit-Change-Id: I1b112b7d04dcf20771c938fd67ac302f6c0b4c3f
Gerrit-PatchSet: 1
Gerrit-Project: labs/striker
Gerrit-Branch: master
Gerrit-Owner: BryanDavis <[email protected]>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits