Faidon has submitted this change and it was merged. Change subject: Add an authdns module & associated role classes ......................................................................
Add an authdns module & associated role classes This adds an authdns module. The module is an uncommon for our tree mixture of a role module, a package replacement (scripts) and internal logic to handle the configuration of the authdns cluster. It serves as a replacement to both dns.pp's authdns parts and the wikimedia-task-dns-auth package, although both are significantly rewritten. A gdnsd module is not provided here, as the software is simple enough to not warrant it (a single Package & Service definition). This also adds role classes for each of ns0/ns1/ns2 (with their current IPs) and a testns class for playing with in labs. These are all UNUSED for now. This is the initial work. Pending work includes adding IPv6 addresses, switching to the commercial GeoIP databases and providing scripts for CI integration. Plus, bug fixes :) Change-Id: Ice119ead68bd7cb5a232f31c778b03791a917012 --- A manifests/role/authdns.pp A modules/authdns/files/authdns-gen-zones.py A modules/authdns/files/authdns-git-pull A modules/authdns/files/authdns-local-update A modules/authdns/files/authdns-update A modules/authdns/manifests/account.pp A modules/authdns/manifests/init.pp A modules/authdns/manifests/monitoring.pp A modules/authdns/manifests/scripts.pp A modules/authdns/templates/config-head.erb A modules/authdns/templates/wikimedia-authdns.conf.erb 11 files changed, 730 insertions(+), 0 deletions(-) Approvals: Mark Bergsma: Looks good to me, but someone else must approve Faidon: Looks good to me, approved jenkins-bot: Verified diff --git a/manifests/role/authdns.pp b/manifests/role/authdns.pp new file mode 100644 index 0000000..dddf2c6 --- /dev/null +++ b/manifests/role/authdns.pp @@ -0,0 +1,77 @@ +# authdns role classes, heavily relying on the authdns role module + +class role::authdns::base { + include standard + + system_role { 'authdns': description => 'Authoritative DNS server' } + + $nameservers = [ + 'ns0.wikimedia.org', + 'ns1.wikimedia.org', + 'ns2.wikimedia.org', + ] + $gitrepo = 'https://gerrit.wikimedia.org/r/p/operations/dns.git' + + include authdns::monitoring +} + +# ns0 @ eqiad +class role::authdns::ns0 inherits role::authdns::base { + $ipv4 = '208.80.154.238' + + interface::ip { 'authdns_ipv4': + interface => 'lo', + address => $ipv4, + prefixlen => '32', + } + + class { 'authdns': + fqdn => 'ns0.wikimedia.org', + ipaddress => $ipv4, + nameservers => $nameservers, + gitrepo => $gitrepo, + } +} + +# ns1 @ pmtpa +class role::authdns::ns1 inherits role::authdns::base { + $ipv4 = '208.80.152.214' + + interface::ip { 'authdns_ipv4': + interface => 'lo', + address => $ipv4, + prefixlen => '32', + } + + class { 'authdns': + fqdn => 'ns1.wikimedia.org', + ipaddress => $ipv4, + nameservers => $nameservers, + gitrepo => $gitrepo, + } +} + +# ns2 @ esams +class role::authdns::ns2 inherits role::authdns::base { + $ipv4 = '91.198.174.4' + + interface::ip { 'authdns_ipv4': + interface => 'eth0', # note: this is interface-bound, unlike ns0/ns1 + address => $ipv4, + prefixlen => '32', + } + + class { 'authdns': + fqdn => 'ns2.wikimedia.org', + ipaddress => $ipv4, + nameservers => $nameservers, + gitrepo => $gitrepo, + } +} + +class role::authdns::testns { + $gitrepo = 'https://gerrit.wikimedia.org/r/p/operations/dns.git' + class { 'authdns': + gitrepo => $gitrepo, + } +} diff --git a/modules/authdns/files/authdns-gen-zones.py b/modules/authdns/files/authdns-gen-zones.py new file mode 100644 index 0000000..e084ea2 --- /dev/null +++ b/modules/authdns/files/authdns-gen-zones.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python + +import argparse +import sys +import os +import time +import jinja2 +from filecmp import dircmp + +HEADER = '''; WARNING! +; This file was automatically generated from a template +; Do NOT edit this file directly! + +''' + + +def parse_args(): + """Sets ups argument parser and its arguments, returns args""" + parser = argparse.ArgumentParser() + parser.add_argument("templatedir", + help="the directory containing the zone templates") + parser.add_argument("zonedir", + help="the directory containing the formatted zones") + parser.add_argument("zones", + help="zones to regenerate (optional, implies -f -k)", + nargs="*") + parser.add_argument("-v", "--verbose", + help="increase output verbosity", + action="store_true", + default=0) + parser.add_argument("-f", "--force", + help="force regeneration of all zones", + action="store_true", + default=0) + parser.add_argument("-k", "--keep", + help="keep zones for which no templates exist", + action="store_true", + default=0) + + return parser.parse_args() + + +def main(): + """main""" + args = parse_args() + + template_env = jinja2.Environment( + loader=jinja2.FileSystemLoader(args.templatedir), + undefined=jinja2.StrictUndefined, + cache_size=0, + ) + + errors = False + context = {} + context['serial'] = time.strftime('%Y%m%d%H', time.gmtime()) + + if args.zones: + zones = args.zones + args.force = True + args.keep = True + else: + zones = os.listdir(args.templatedir) + + for filename in zones: + templatepath = os.path.join(args.templatedir, filename) + zonepath = os.path.join(args.zonedir, filename) + context['zonename'] = filename + + # only process regular files and symlinks + if not os.path.isfile(templatepath) or filename.startswith('.'): + if args.zones: + print "Skipping non-existent zone", filename + continue + + try: + if not args.force and ( + os.path.getmtime(templatepath) <= + os.path.getmtime(zonepath)): + continue + except OSError: + # destination file not found + pass + + if args.verbose: + print 'Processing zone', filename + + try: + template = template_env.get_template(filename) + output = template.render(context) + except jinja2.exceptions.TemplateSyntaxError, exc: + print 'Skipping zone %s, syntax error on line %d: %s' % \ + (filename, exc.lineno, exc.message) + errors = True + continue + except jinja2.exceptions.TemplateError, exc: + print 'Skipping zone %s, could not parse: %s' % \ + (filename, str(exc)) + errors = True + continue + + # Write zonefile + open(zonepath, 'w').write(HEADER + output) + + if not args.keep: + # cleanup removed zones + dcmp = dircmp(args.templatedir, args.zonedir) + for filename in dcmp.right_only: + if filename.startswith('.'): + continue + if args.verbose: + print "Cleaning up zone", filename + os.unlink(os.path.join(args.zonedir, filename)) + + if errors: + sys.exit(1) + +if __name__ == '__main__': + main() + +# vim: ts=4 sts=4 et ai shiftwidth=4 fileencoding=utf-8 diff --git a/modules/authdns/files/authdns-git-pull b/modules/authdns/files/authdns-git-pull new file mode 100644 index 0000000..80ebd4c --- /dev/null +++ b/modules/authdns/files/authdns-git-pull @@ -0,0 +1,91 @@ +#!/bin/sh + +# Simple script that substitutes "git pull" but making sure that: +# - the working tree has no untracked files +# - the working tree has no unstaged changes +# - the working tree has no staged but uncommited changes +# - the working tree has no commits that are not present in FETCH_HEAD +# - the user has reviewed and accepted the changes (unless --skip-review is given) +# +# This is basically estabilishing that the repository is being used as a +# replica and that a "pull" would only resync with remote +# +# Created by Faidon Liambotis, Jul 2013 + +REVIEW="true" +if [ "$1" = "--skip-review" ]; then + REVIEW="false" + shift +fi +REMOTE=$1 +BRANCH=$2 + +die() { echo >&2 "E: $*"; exit 1; } + +if [ -z "$REMOTE" ]; then + die "no remote specified" +elif [ -z "$BRANCH" ]; then + BRANCH="master" +fi + +if test "$(git rev-parse --is-inside-work-tree 2>/dev/null)" = false; then + die "not inside a working tree" +fi + +if ! git rev-parse --verify $BRANCH >/dev/null; then + die "could not verify $BRANCH" +fi + +if [ $(git rev-parse HEAD) != $(git rev-parse $BRANCH) ]; then + cur=$(git rev-parse --abbrev-ref HEAD) + die "working tree HEAD is pointed to '$cur', not '$BRANCH'" +fi + +untracked=$(git ls-files --exclude-standard --others) +if [ "$untracked" != "" ]; then + die "untracked files present: $untracked" +fi + +if ! git diff-files --quiet --ignore-submodules; then + die "unstaged changes present" +fi + +if ! git diff-index --cached --quiet --ignore-submodules HEAD --; then + die "staged but uncommited changes present" +fi + +if ! git fetch $REMOTE $BRANCH 2>/dev/null; then + die "could not fetch $REMOTE $BRANCH" +fi + +# store FETCH_HEAD here to avoid race conditions +HEAD=$(git rev-parse --verify --revs-only HEAD) +NEW=$(git rev-parse --verify --revs-only FETCH_HEAD) + +if [ "$HEAD" = "$NEW" ]; then + # up-to-date, nothing to do + exit 0 +fi + +revlist=$(git rev-list -1 $HEAD --not $NEW) +if [ "$revlist" != "" ]; then + echo $revlist + die "HEAD has diverged from $REMOTE, please reconcile first" +fi + + +if [ "$REVIEW" = "true" ]; then + echo "Reviewing ${NEW}..." + echo "" + PAGER="" git diff -p --stat --no-prefix --minimal --color ${HEAD}..${NEW} + echo "" + echo -n "Merge these changes? (yes/no)? " + read answer + if [ "x${answer}" != "xyes" ]; then + echo "Aborting merge." + exit 1 + fi +fi + +git merge --ff-only $NEW +exit 0 diff --git a/modules/authdns/files/authdns-local-update b/modules/authdns/files/authdns-local-update new file mode 100644 index 0000000..df20e8e --- /dev/null +++ b/modules/authdns/files/authdns-local-update @@ -0,0 +1,114 @@ +#!/bin/bash +# +# Shell script that pulls zone templates from the origin or master DNS server, +# regenerate zones & configuration and reload the DNS server. +# +# Written by Faidon Liambotis, Jul 2013 + +set -e + +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +CONFFILE=/etc/wikimedia-authdns.conf + +# Source the configuration file +[ -f $CONFFILE ] && . $CONFFILE + +die() { echo >&2 "E: $*"; exit 1; } + +if [ "$(id -u)" -ne "0" ]; then + die "this script needs root" +fi + +# setup locking; only one copy of this may be running at the same time +LOCKFILE=/var/lock/authdns-local-update +LOCKFD=9 +lock() { flock -xn $LOCKFD; } +unlock() { rm -f $LOCKFILE; } +eval "exec $LOCKFD>\"$LOCKFILE\""; trap unlock EXIT + +if ! flock -xn $LOCKFD; then + die "failed to lock, another update running?" +fi + +while :; do + case "$1" in + --skip-reload) + SKIP_RELOAD="true" + shift + ;; + --skip-review) + SKIP_REVIEW="true" + shift + ;; + *) + break + ;; + esac +done + +REMOTE="" +if [ -z "$1" ]; then + if [ -z "$ORIGIN" ]; then + die "no master given and no origin defined in config" + fi + REMOTE=$ORIGIN + if [ "$SKIP_REVIEW" = "true" ]; then + PULL_ARGS="--skip-review" + fi +else + REMOTE="ssh://${1}${WORKINGDIR}" + PULL_ARGS="--skip-review" +fi +echo "Pulling the current revision from $REMOTE" +(cd $WORKINGDIR; sudo -u authdns authdns-git-pull $PULL_ARGS $REMOTE) + +if [ ! -e "/etc/gdnsd/config-head" ]; then + die "config-head not found, system misconfigured?" +fi +if [ ! -e "$WORKINGDIR/templates" ]; then + die "templates not found, system misconfigured?" +fi +if [ ! -e "$WORKINGDIR/config-geo" ]; then + die "config-geo not found, system misconfigured?" +fi + +echo "Generating zonefiles from zone templates" +authdns-gen-zones $WORKINGDIR/templates /etc/gdnsd/zones + +echo "Generating gdnsd config" +cp -f $WORKINGDIR/config-geo /etc/gdnsd/ +cp -f /etc/gdnsd/config /etc/gdnsd/config~ 2>/dev/null || true +cat /etc/gdnsd/config-head /etc/gdnsd/config-geo > /etc/gdnsd/config + +echo "Doing sanity checks" +if [ ! -s "/etc/gdnsd/config" ]; then + die "config seems empty, aborting" +elif [ `ls /etc/gdnsd/zones |wc -l` -le 10 ]; then + die "less than 10 zones, something's probably wrong, aborting"; +fi + +# initial run, before gdnsd was installed +if ! which gdnsd > /dev/null || [ "$SKIP_RELOAD" = "true" ]; then + rm -f /etc/gdnsd/config~ + exit 0 +fi + +if ! gdnsd checkconf 2>/dev/null; then + if [ -f /etc/gdnsd/config~ ]; then + mv /etc/gdnsd/config~ /etc/gdnsd/config + fi + die "gdnsd checkconf failed, aborting" +fi + +### reload + +if ! cmp --quiet /etc/gdnsd/config~ /etc/gdnsd/config; then + rm -f /etc/gdnsd/config~ + echo "Reloading zones & config" + gdnsd force-reload +else + rm -f /etc/gdnsd/config~ + echo "Reloading zones" + gdnsd reload +fi diff --git a/modules/authdns/files/authdns-update b/modules/authdns/files/authdns-update new file mode 100644 index 0000000..995e6c8 --- /dev/null +++ b/modules/authdns/files/authdns-update @@ -0,0 +1,57 @@ +#!/bin/bash +# +# Shell script that takes care of running authdns-local-update for each +# nameserver via SSH, optionally skipping failed ones. +# +# Written by Faidon Liambotis, Jul 2013 based on previous work by Mark Bergsma + +set -e + +CONFFILE=/etc/wikimedia-authdns.conf + +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +# Source the configuration file +[ -f $CONFFILE ] && . $CONFFILE + +SSH_OPTIONS="-oCheckHostIP=no -oUserKnownHostsFile=/dev/null -oBatchMode=yes" +SSH_USER="-l authdns -i /srv/authdns/.ssh/id_rsa" + +if [ -z "$NAMESERVERS" -o -z "$FQDN" ]; then + echo "Missing config file options, system misconfigured" + exit 1 +fi + +SKIP="" +while [ -n "$1" ]; do + if [ "$1" = "-s" ]; then + # Skip the following slaves + SKIP="$SKIP $2" + fi + shift +done + +# update the local instance first -- this may call a review interactively +echo "Updating $FQDN (self)..." +# this might seem silly at first, ssh'ing to self; however, this is a paranoid +# step to ensure that we're running in the exact same way as the slaves and if +# it fails, it fail for all of them, instead of having a split-brain. +ssh $SSH_OPTIONS $SSH_USER $FQDN authdns-local-update + +for slave in $NAMESERVERS; do + if [ "$FQDN" = "$slave" ]; then + continue + fi + for skip in $SKIP; do + if [ "$skip" = "$slave" ]; then + echo ""; echo "Skipping $slave." + continue 2 + fi + done + + echo ""; echo "Updating $slave..." + # sync from us + ssh $SSH_OPTIONS $SSH_USER $slave authdns-local-update $FQDN +done + +echo ""; echo "DONE!" diff --git a/modules/authdns/manifests/account.pp b/modules/authdns/manifests/account.pp new file mode 100644 index 0000000..6d05da1 --- /dev/null +++ b/modules/authdns/manifests/account.pp @@ -0,0 +1,65 @@ +# == Class authdns::account +# Sets up user, group, sudo SSH keys & git-shell commands for authdns +# +class authdns::account { + $user = 'authdns' + $group = 'authdns' + $home = '/srv/authdns' + + user { $user: + ensure => present, + gid => $group, + home => $home, + system => true, + managehome => true, + shell => '/usr/bin/git-shell', + } + group { $group: + ensure => 'present', + } + + sudo_user { $user: + privileges => 'ALL=NOPASSWD: /usr/local/sbin/authdns-local-update', + } + + file { "${home}/.ssh": + ensure => 'directory', + owner => $user, + group => $group, + mode => '0700', + require => [ User[$user], Group[$group] ], + } + file { "${home}/.ssh/id_rsa": + ensure => 'present', + owner => $user, + group => $group, + mode => '0400', + source => 'puppet:///modules/private/authdns/id_rsa', + } + file { "${home}/.ssh/id_rsa.pub": + ensure => 'present', + owner => $user, + group => $group, + mode => '0400', + source => 'puppet:///modules/private/authdns/id_rsa.pub', + } + file { "${home}/.ssh/authorized_keys": + ensure => 'link', + target => 'id_rsa.pub', + } + + file { "${home}/git-shell-commands": + ensure => 'directory', + owner => $user, + group => $group, + require => [ User[$user], Group[$group] ], + } + file { "${home}/git-shell-commands/authdns-local-update": + ensure => 'present', + owner => $user, + group => $group, + mode => '0550', + content => "#!/bin/sh\nexec /usr/bin/sudo authdns-local-update \$@\n", + require => [ User[$user], Group[$group] ], + } +} diff --git a/modules/authdns/manifests/init.pp b/modules/authdns/manifests/init.pp new file mode 100644 index 0000000..5bd4b01 --- /dev/null +++ b/modules/authdns/manifests/init.pp @@ -0,0 +1,112 @@ +# == Class authdns +# A class to implement Wikimedia's authoritative DNS system +# +class authdns( + $fqdn = $::fqdn, + $nameservers = [ $::fqdn ], + $ipaddress = undef, + $ipaddress6 = undef, + $gitrepo = undef, +) { + require authdns::account + require authdns::scripts + + class { '::geoip': + data_provider => 'package', + } + Class['::geoip'] -> Class['authdns'] + + package { 'gdnsd': + ensure => installed, + } + + service { 'gdnsd': + ensure => 'running', + hasrestart => true, + hasstatus => true, + require => Package['gdnsd'], + } + + # the package creates this, but we want to set up the config before we + # install the package, so that the daemon starts up with a well-known + # config that leaves no window where it'd refuse to answer properly + file { '/etc/gdnsd': + ensure => 'directory', + owner => 'root', + group => 'root', + mode => '0755', + } + # to be replaced with config + include statement, post-1.9.0 + file { '/etc/gdnsd/config-head': + ensure => 'present', + owner => 'root', + group => 'root', + mode => '0444', + content => template("${module_name}/config-head.erb"), + require => File['/etc/gdnsd'], + } + file { '/etc/gdnsd/zones': + ensure => 'directory', + owner => 'root', + group => 'root', + mode => '0755', + } + + $workingdir = '/srv/authdns/git' # export to template + + file { '/etc/wikimedia-authdns.conf': + ensure => 'present', + mode => '0444', + owner => 'root', + group => 'root', + content => template("${module_name}/wikimedia-authdns.conf.erb"), + } + + # do the initial clone via puppet + git::clone { $workingdir: + directory => $workingdir, + origin => $gitrepo, + branch => 'master', + owner => 'authdns', + group => 'authdns', + notify => Exec['authdns-local-update'], + } + + exec { 'authdns-local-update': + command => '/usr/local/sbin/authdns-local-update --skip-review', + user => root, + refreshonly => true, + timeout => 60, + require => [ + File['/etc/wikimedia-authdns.conf'], + File['/etc/gdnsd/config-head'], + Git::Clone['/srv/authdns/git'], + ], + # we prepare the config even before the package gets installed, leaving + # no window where service would be started and answer with REFUSED + before => Package['gdnsd'], + } + + # export the SSH host key for service hostname/IP keys too + if $fqdn != $::fqdn { + @@sshkey { $fqdn: + ensure => 'present', + type => 'ssh-rsa', + key => $::sshrsakey, + } + } + if $ipaddress { + @@sshkey { $ipaddress: + ensure => 'present', + type => 'ssh-rsa', + key => $::sshrsakey, + } + } + if $ipaddress6 { + @@sshkey { $ipaddress6: + ensure => 'present', + type => 'ssh-rsa', + key => $::sshrsakey, + } + } +} diff --git a/modules/authdns/manifests/monitoring.pp b/modules/authdns/manifests/monitoring.pp new file mode 100644 index 0000000..8368810 --- /dev/null +++ b/modules/authdns/manifests/monitoring.pp @@ -0,0 +1,22 @@ +# == Class authdns::monitoring +# Monitoring checks for authdns, specific to Wikimedia setup +# +class authdns::monitoring { + Class['authdns'] -> Class['authdns-monitoring'] + + if $authdns::ipaddress { + $monitor_ip = $authdns::ipaddress + } else { + $monitor_ip = $::ipaddress + } + + monitor_host { $authdns::fqdn: + ip_address => $monitor_ip, + } + + monitor_service { 'auth dns': + host => $authdns::fqdn, + description => 'Auth DNS', + check_command => 'check_dns!www.wikipedia.org' + } +} diff --git a/modules/authdns/manifests/scripts.pp b/modules/authdns/manifests/scripts.pp new file mode 100644 index 0000000..afb0edc --- /dev/null +++ b/modules/authdns/manifests/scripts.pp @@ -0,0 +1,48 @@ +# == Class authdns::monitoring +# Scripts used by the authdns system. These used to be in a package, +# but we don't do that anymore and provisioning them here instead. +# +class authdns::scripts { + if ! defined(Package['python-jinja2']){ + package { 'python-jinja2': + ensure => present, + } + } + + if ! defined(Package['git-core']){ + package { 'git-core': + ensure => present, + } + } + + file { '/usr/local/bin/authdns-gen-zones': + ensure => present, + mode => '0555', + owner => 'root', + group => 'root', + source => "puppet:///modules/${module_name}/authdns-gen-zones.py", + } + file { '/usr/local/sbin/authdns-update': + ensure => present, + mode => '0555', + owner => 'root', + group => 'root', + source => "puppet:///modules/${module_name}/authdns-update", + } + + file { '/usr/local/sbin/authdns-local-update': + ensure => present, + mode => '0555', + owner => 'root', + group => 'root', + source => "puppet:///modules/${module_name}/authdns-local-update", + } + + file { '/usr/local/sbin/authdns-git-pull': + ensure => present, + mode => '0555', + owner => 'root', + group => 'root', + source => "puppet:///modules/${module_name}/authdns-git-pull", + } +} diff --git a/modules/authdns/templates/config-head.erb b/modules/authdns/templates/config-head.erb new file mode 100644 index 0000000..b105c65 --- /dev/null +++ b/modules/authdns/templates/config-head.erb @@ -0,0 +1,20 @@ +options => { +<% if (! @ipaddress.nil? || ! @ipaddress6.nil?) -%> + listen = [ +<% if (! @ipaddress.nil?) -%> + <%= @ipaddress %>, +<% end -%> +<% if (! @ipaddress6.nil?) -%> + <%= @ipaddress6 %>, +<% end -%> + ], +<% end -%> + http_listen = [ + 127.0.0.1, + ::1, + ], + zones_default_ttl = 43200, + include_optional_ns = true, + # don't inotify on zonefiles but wait for HUP + zones_rfc1035_auto = false, +} diff --git a/modules/authdns/templates/wikimedia-authdns.conf.erb b/modules/authdns/templates/wikimedia-authdns.conf.erb new file mode 100644 index 0000000..d268ba4 --- /dev/null +++ b/modules/authdns/templates/wikimedia-authdns.conf.erb @@ -0,0 +1,4 @@ +NAMESERVERS="<%= @nameservers.join(' ') %>" +FQDN="<%= @fqdn %>" +WORKINGDIR=<%= @workingdir %> +ORIGIN="<%= @gitrepo %>" -- To view, visit https://gerrit.wikimedia.org/r/74119 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: merged Gerrit-Change-Id: Ice119ead68bd7cb5a232f31c778b03791a917012 Gerrit-PatchSet: 3 Gerrit-Project: operations/puppet Gerrit-Branch: production Gerrit-Owner: Faidon <fai...@wikimedia.org> Gerrit-Reviewer: Akosiaris <akosia...@wikimedia.org> Gerrit-Reviewer: BBlack <bbl...@wikimedia.org> Gerrit-Reviewer: Faidon <fai...@wikimedia.org> Gerrit-Reviewer: Mark Bergsma <m...@wikimedia.org> Gerrit-Reviewer: jenkins-bot _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits