#! /usr/bin/env python3 """=cut =head1 NAME git_commit_behind - Munin plugin to monitor local git repositories and report how many commits behind their remote they are =head1 NOTES This plugin is similar to how apt_all works for apt packages. To be able to check how behind a git repository is, we need to run git fetch. To avoid fetching all repos every 5 minutes (for each munin period) and thus slowing down the data collection, the git fetch operation is only randomly triggered (based on env.update.probability). In case of very time-consuming update operations, you can run them in a separate cron job. =head1 REQUIREMENTS =over 4 =item Python3 =item Git =back =head1 INSTALLATION Link this plugin, as usual. For example : ln -s /path/to/git_commit_behind /etc/munin/plugins/git_commit_behind If you wish to update the repositories via cron and not during the plugin execution (cf CONFIGURATION section), you need a dedicated cron job. For example, you can use the following cron : # If the git_commit_behind plugin is enabled, fetch git repositories randomly # according to the plugin configuration. # By default : once an hour (12 invocations an hour, 1 in 12 chance that the # update will happen), but ensure that there will never be more than two hours # (7200 seconds) interval between updates. */5 * * * * root if [ -x /etc/munin/plugins/git_commit_behind ]; then /usr/sbin/munin-run git_commit_behind update >/dev/null; fi =head1 CONFIGURATION Use your "/etc/munin/plugin-conf.d/munin-node" to configure this plugin. [git_commit_behind] user [user] env.git_path /path/to/git env.update.mode [munin|cron] env.update.probability 12 env.update.maxinterval 7200 user [user] : required, the owner of the repository checkouts in case of multiple different owners, use root env.git_path : optional (default : /usr/bin/git), the path to the git binary. env.update.mode : optional (default : munin), the update mode. munin : repositories are git fetched during the pugin execution cron : a dedicated cron job needs to be used to update the repositories env.update.probability : optional (default : 12), runs the update randomly (1 in chances) env.update.maxinterval : optional (default : 7200), ensures that the update is run at least every seconds Then, for each repository you want to check, you need the following configuration block under the git_commit_behind section: env.repo.[repoCode].path /path/to/local/repo env.repo.[repoCode].name Repo Name env.repo.[repoCode].user user env.repo.[repoCode].warning 10 env.repo.[repoCode].critical 100 [repoCode] can only contain letters, numbers and underscores. path : mandatory, the local path to your git repository name : optional (default : [repoCode]), a cleaner name that will be displayed user : optional (default : empty), the owner of the repository if set and different from the user running the plugin, the git commands will be executed as this user warning : optional (default 10), the warning threshold critical : optional (default 100), the critical threshold For example : [git_commit_behind] user root env.repo.munin_contrib.path /opt/munin-contrib env.repo.munin_contrib.name Munin Contrib env.repo.other_repo.path /path/to/other-repo env.repo.other_repo.name Other Repo =head1 MAGIC MARKERS #%# family=auto #%# capabilities=autoconf =head1 VERSION 1.0.0 =head1 AUTHOR Neraud (https://github.com/Neraud) =head1 LICENSE GPLv2 =cut""" import logging import os from pathlib import Path import pwd from random import randint import re from shlex import quote from subprocess import check_output, call, DEVNULL, CalledProcessError import sys import time plugin_version = "1.0.0" if int(os.getenv('MUNIN_DEBUG', 0)) > 0: logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)-7s %(message)s') current_user = pwd.getpwuid(os.geteuid())[0] conf = { 'git_path': os.getenv('git_path', '/usr/bin/git'), 'state_file': os.getenv('MUNIN_STATEFILE'), 'update_mode': os.getenv('update.mode', 'munin'), 'update_probability': int(os.getenv('update.probability', '12')), 'update_maxinterval': int(os.getenv('update.maxinterval', '7200')) } repo_codes = set(re.search('repo\.([^.]+)\..*', elem).group(1) for elem in os.environ.keys() if elem.startswith('repo.')) repos_conf = {} for code in repo_codes: repos_conf[code] = { 'name': os.getenv('repo.%s.name' % code, code), 'path': os.getenv('repo.%s.path' % code, None), 'user': os.getenv('repo.%s.user' % code, None), 'warning': os.getenv('repo.%s.warning' % code, '10'), 'critical': os.getenv('repo.%s.critical' % code, '100') } def print_config(): print('graph_title Git repositories - Commits behind') print('graph_args --base 1000 -r --lower-limit 0') print('graph_vlabel number of commits behind') print('graph_scale yes') print('graph_info This graph shows the number of commits behind' + ' for each configured git repository') print('graph_category file_transfer') print('graph_order %s' % ' '.join(repo_codes)) for repo_code in repos_conf.keys(): print('%s.label %s' % (repo_code, repos_conf[repo_code]['name'])) print('%s.warning %s' % (repo_code, repos_conf[repo_code]['warning'])) print('%s.critical %s' % (repo_code, repos_conf[repo_code]['critical'])) def generate_git_command(repo_conf, git_command): if not repo_conf['user'] or repo_conf['user'] == current_user: cmd = [quote(conf['git_path'])] + git_command else: shell_cmd = 'cd %s ; %s %s' % ( quote(repo_conf['path']), quote(conf['git_path']), ' '.join(git_command)) cmd = ['su', '-', repo_conf['user'], '-s', '/bin/sh', '-c', shell_cmd] return cmd def execute_git_command(repo_conf, git_command): cmd = generate_git_command(repo_conf, git_command) return check_output(cmd, cwd=repo_conf['path']).decode('utf-8').rstrip() def print_info(): if not os.access(conf['git_path'], os.X_OK): print('Git (%s) is missing, or not executable !' % conf['git_path'], file=sys.stderr) sys.exit(1) for repo_code in repos_conf.keys(): logging.debug(' - %s' % repo_code) try: remote_branch = execute_git_command( repos_conf[repo_code], ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']) logging.debug('remote_branch = %s' % remote_branch) commits_behind = execute_git_command( repos_conf[repo_code], ['rev-list', 'HEAD..%s' % remote_branch, '--count']) print('%s.value %d' % (repo_code, int(commits_behind))) except CalledProcessError as e: logging.error('Error executing git command : %s', e) except FileNotFoundError as e: logging.error('Repo not found at path %s' % repos_conf[repo_code]['path']) def check_update_repos(mode): if not conf['state_file']: logging.error('Munin state file unavailable') sys.exit(1) if mode != conf['update_mode']: logging.debug('Wrong mode, skipping') return if not os.path.isfile(conf['state_file']): logging.debug('No state file -> updating') do_update_repos() elif (os.path.getmtime(conf['state_file']) + conf['update_maxinterval'] < time.time()): logging.debug('State file last modified too long ago -> updating') do_update_repos() elif randint(1, conf['update_probability']) == 1: logging.debug('Recent state, but random matched -> updating') do_update_repos() else: logging.debug('Recent state and random missed -> skipping') def do_update_repos(): for repo_code in repos_conf.keys(): try: logging.info('Fetching repo %s' % repo_code) execute_git_command(repos_conf[repo_code], ['fetch']) except CalledProcessError as e: logging.error('Error executing git command : %s', e) except FileNotFoundError as e: logging.error('Repo not found at path %s' % repos_conf[repo_code]['path']) logging.debug('Updating the state file') # 'touch' the state file to update its last modified date Path(conf['state_file']).touch() if len(sys.argv) > 1: action = sys.argv[1] if action == 'config': print_config() elif action == 'autoconf': errors = [] if not conf['state_file']: errors.append('munin state file unavailable') if os.access(conf['git_path'], os.X_OK): test_git = call([conf['git_path'], '--version'], stdout=DEVNULL) if test_git != 0: errors.append('git seems to be broken ?!') else: errors.append('git is missing or not executable') if errors: print('no (%s)' % ', '.join(errors)) else: print('yes') elif action == 'version': print('Git commit behind Munin plugin, version {0}'.format( plugin_version)) elif action == 'update': check_update_repos('cron') else: logging.warn("Unknown argument '%s'" % action) sys.exit(1) else: if conf['update_mode'] == 'munin': check_update_repos('munin') print_info()