Skip to content
aurbot 9.51 KiB
Newer Older
Seblu's avatar
Seblu committed
#!/usr/bin/python3
# coding: utf-8

# aurbot - Archlinux User Repository Builder Bot
Seblu's avatar
Seblu committed
# Copyright © 2015 Sébastien Luttringer
Seblu's avatar
Seblu committed
#
# Started, October 30th 2011
#
# This program 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 2
# of the License, or (at your option) any later version.
#
# This program 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 this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

from argparse import ArgumentParser
from configparser import ConfigParser
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
Seblu's avatar
Seblu committed
from json import load as jload, dump as jdump, loads as jloads
Seblu's avatar
Seblu committed
from logging import debug, warning, info, error
from logging import StreamHandler, getLogger, Formatter, DEBUG, INFO
Seblu's avatar
Seblu committed
from os import chdir, environ, getcwd, mkdir
from os.path import exists, join
from subprocess import Popen, check_call, DEVNULL, PIPE
from systemd.daemon import notify
Seblu's avatar
Seblu committed
from tarfile import open as tar
from tempfile import TemporaryDirectory
Seblu's avatar
Seblu committed
from time import sleep, time, strftime, localtime
Seblu's avatar
Seblu committed
from urllib.request import urlopen, Request
Seblu's avatar
Seblu committed
from xdg.BaseDirectory import save_config_path, save_data_path
Seblu's avatar
Seblu committed

AUR_URL = 'https://aur.archlinux.org'
USER_AGENT = "aurbot"
XDG_DIRECTORY = "aurbot"

ERR_USAGE = 1
ERR_FATAL = 2
ERR_ABORT = 3
ERR_UNKNOWN = 4

Seblu's avatar
Seblu committed
class ABFormatter(Formatter):
	'''
	Customer logging formater
	'''
	def __init__(self, fmt="[%(levelname)s] %(msg)s"):
		Formatter.__init__(self, fmt)

	def format(self, record):
		format_orig = self._style._fmt
		if record.levelno == INFO and getLogger(record.name).getEffectiveLevel() != DEBUG:
			self._style._fmt =  "%(msg)s"
		result = Formatter.format(self, record)
		self._style._fmt = format_orig
		return result

Seblu's avatar
Seblu committed

class AURPackage(dict):
	'''
	Abstract AUR package action
	'''

	def __init__(self, name, timeout=None):
		self.name = name
		debug("getting %s aur infos" % self.name)
		url = "%s/rpc.php?type=info&arg=%s" % (AUR_URL, name)
		url_req = Request(url, headers={"User-Agent": USER_AGENT})
Seblu's avatar
Seblu committed
		debug("Requesting url: %s (timeout: %s)" % (url, timeout))
Seblu's avatar
Seblu committed
		url_fd = urlopen(url_req, timeout=timeout)
		d = jloads(url_fd.read().decode("utf-8"))
		if d["version"] != 1:
			raise Exception("Unknown AUR Backend version: %s" % d["version"])
		if len(d["results"]) == 0:
			raise Exception("No such package: %s" % name)
Seblu's avatar
Seblu committed
		self._info = d["results"]

	def __getattr__(self, name):
		for k, v in self._info.items():
			if name == k.lower():
				return v
		raise AttributeError()

	def __repr__(self):
Seblu's avatar
Seblu committed
		return "%s %s" % (self.name, self.version)
Seblu's avatar
Seblu committed

	def extract(self, path):
		'''
		Extract aur source tarball inside a directory path
		'''
		fo = urlopen('%s/%s' % (AUR_URL, self.urlpath))
		tarball = tar(mode='r|*', fileobj=fo)
		tarball.extractall(path)
		fo.close()


Seblu's avatar
Seblu committed
class LocalPackage(dict):
	'''Local package data abstraction'''
Seblu's avatar
Seblu committed

Seblu's avatar
Seblu committed
	def __init__(self, name):
		self.name = name
		self.path = join(save_data_path(XDG_DIRECTORY), name)
		debug("local path is: %s" % self.path)
		if not exists(self.path):
			mkdir(self.path)

Seblu's avatar
Seblu committed
	@property
	def logdir(self):
		logdir = join(self.path, "log")
		if not exists(logdir):
			mkdir(logdir)
		return logdir

Seblu's avatar
Seblu committed
	def getlastX(self, X):
Seblu's avatar
Seblu committed
		try:
Seblu's avatar
Seblu committed
			return int(open(join(self.path, X), "r").read())
		except Exception as exp:
			debug("Failed to read %s time: %s" % (X, exp))
			return 0

	def setlastX(self, X, value):
		try:
			open(join(self.path, X), "w").write("%d\n" % value)
		except Exception as exp:
			error("Failed to save %s time: %s" % (X, exp))

	lastbuild = property(lambda x: LocalPackage.getlastX(x, "lastbuild"),
						 lambda x, y: LocalPackage.setlastX(x, "lastbuild", y))
	lastmodified = property(lambda x: LocalPackage.getlastX(x, "lastmodified"),
						 lambda x, y: LocalPackage.setlastX(x, "lastmodified", y))

def send_report(config, localpkg, aurpkg, status, logfile):
	'''Send build notification'''
	# generate message
	msg = MIMEMultipart()
	msg['Subject'] = 'Build %s for %s %s' % (
		"successful" if status else "failure", localpkg.name, aurpkg.version)
	msg['From'] = "Aurbot"
	msg['To'] = config["notify"]

	# attach logfile
	with open(logfile, "r") as fd:
		mt = MIMEText(fd.read())
	msg.attach(mt)

	# send message
	proc = Popen(["sendmail", "-i", "-t"], stdin=PIPE, close_fds=True)
	proc.stdin.write(msg.as_bytes())
	proc.stdin.close()
	proc.wait()

Seblu's avatar
Seblu committed
def build(config, localpkg, aurpkg):
	'''
	Build and commit a package
	Notify if succeeded
	'''
Seblu's avatar
Seblu committed
	# find build dir
	build_dir = TemporaryDirectory()
Seblu's avatar
Seblu committed
	debug("Build dir is %s" % build_dir.name)
Seblu's avatar
Seblu committed
	# extract tarball
Seblu's avatar
Seblu committed
	debug("Extracting aur tarball")
Seblu's avatar
Seblu committed
	aurpkg.extract(build_dir.name)
Seblu's avatar
Seblu committed
	# log files
	fp = join(localpkg.logdir, strftime("build-%Y-%m-%d-%H-%M-%S.log", localtime(time())))
	debug("Build log file path: %s" % fp)
Seblu's avatar
Seblu committed
	with open(fp, "w") as fd:
		try:
			cwd = getcwd()
			chdir("%s/%s" % (build_dir.name, aurpkg.name))

			# build
			info("Starting build command")
			debug(config["build_cmd"])
			fd.write("Build command: %s\n" % config["build_cmd"])
Seblu's avatar
Seblu committed
			start_time = time()
			try:
				check_call(config["build_cmd"], stdin=DEVNULL, stdout=fd,
					stderr=fd, shell=True, close_fds=True)
			except Exception as exp:
				error("Build command failure: %s" % exp)
				raise
			end_time = time()
			info("Build duration: %.2fs" % (end_time - start_time))
			fd.write("Build duration: %.2fs\n" % (end_time - start_time))
Seblu's avatar
Seblu committed

			# commit
			if "commit_cmd" in config:
				info("Starting commit command")
				debug(config["commit_cmd"])
				fd.write("Commit command: %s\n" % config["commit_cmd"])
Seblu's avatar
Seblu committed
				start_time = time()
				try:
					check_call(config["commit_cmd"], stdin=DEVNULL, stdout=fd,
						stderr=fd, shell=True, close_fds=True)
				except Exception as exp:
					error("Commit command failure: %s" % exp)
					raise
				end_time = time()
				info("Commit duration: %.2fs" % (end_time - start_time))
				fd.write("Commit duration: %.2fs\n" % (end_time - start_time))
Seblu's avatar
Seblu committed

			# register success
			localpkg.lastbuild = time()
			localpkg.lastmodified = aurpkg.lastmodified
			status = True
		except Exception as exp:
			status = False
Seblu's avatar
Seblu committed
		finally:
			chdir(cwd)

	# notify of success
	if "notify" in config:
		send_report(config, localpkg, aurpkg, status, fp)
Seblu's avatar
Seblu committed

Seblu's avatar
Seblu committed
def event_loop(packages, timeout):
Seblu's avatar
Seblu committed
	'''
	program roundabout
	'''
	while True:
		for name, config in packages.items():
			if name == "DEFAULT":
				continue
Seblu's avatar
Seblu committed
			info("[%s]" % name)
Seblu's avatar
Seblu committed
			if "build_cmd" not in config:
Seblu's avatar
Seblu committed
				error("build_cmd is missing in config file")
				continue
Seblu's avatar
Seblu committed
			try:
				aur = AURPackage(name)
			except Exception as exp:
				error("Unable to get AUR package info: %s" % exp)
				continue
Seblu's avatar
Seblu committed
			local = LocalPackage(name)
Seblu's avatar
Seblu committed
			# For security, if the maintainer has changed we pass
Seblu's avatar
Seblu committed
			maintainer = config.get("maintainer")
			if maintainer != aur.maintainer:
Seblu's avatar
Seblu committed
				error("Invalid maintainer for package %s" % name)
Seblu's avatar
Seblu committed
				debug("registered maintainer: %s" % maintainer)
Seblu's avatar
Seblu committed
				debug("AUR maintainer: %s" % aur.maintainer)
Seblu's avatar
Seblu committed
				continue
			# checks update
Seblu's avatar
Seblu committed
			debug("AUR last modified: %s" % aur.lastmodified)
			debug("Local last modified: %s" % local.lastmodified)
			debug("Local last build: %s" % local.lastbuild)
			# build new aur version
			if aur.lastmodified > local.lastmodified:
				info("New version available: %s" % aur.version)
Seblu's avatar
Seblu committed
				build(config, local, aur)
Seblu's avatar
Seblu committed
			# re-build package when force time is passed
			elif "force" in config:
				if config["force"].isdigit() is False:
					warning("Invalid force value, ignore it")
					continue
				# if lastbuild not exists, it will be equal to 0
				# too small to be > to time() even with big force time
				now = int(time())
				force = int(config["force"])
				debug("Force at: %ss, currently: %ss" % (force, now - local.lastbuild))
				if local.lastbuild + force <= now:
					info("Forced update")
Seblu's avatar
Seblu committed
					build(config, local, aur)
Seblu's avatar
Seblu committed
			else:
				info("Nothing to do")
Seblu's avatar
Seblu committed
		# night is coming, save cache
Seblu's avatar
Seblu committed
		debug("waiting for %ds" % timeout)
		sleep(timeout)

def parse_argv():
	'''Parse command line arguments'''
	# load parser
	parser = ArgumentParser()
Seblu's avatar
Seblu committed
	parser.add_argument("-c", "--config", help="packages config file path")
Seblu's avatar
Seblu committed
	parser.add_argument("-s", "--sleep", type=int, default=86400, help="sleep interval between checks")
	parser.add_argument("-d", "--debug", action="store_true", help="debug mode")
Seblu's avatar
Seblu committed
	parser.epilog = "You could set $XDG_DATA_HOME to change the path of the local package cache."
Seblu's avatar
Seblu committed
	# parse it!
	args = parser.parse_args()
	# set global debug mode
	if args.debug:
		getLogger().setLevel(DEBUG)
	# set default paths
Seblu's avatar
Seblu committed
	if args.config is None:
		args.config = join(save_config_path(XDG_DIRECTORY), "packages.conf")
Seblu's avatar
Seblu committed
	return args

def main():
	'''Program entry point'''
	try:
Seblu's avatar
Seblu committed
		# set logger config
		hdlr = StreamHandler()
		hdlr.setFormatter(ABFormatter())
		getLogger().addHandler(hdlr)
Seblu's avatar
Seblu committed
		getLogger().setLevel(DEBUG if "AURBOT_DEBUG" in environ else INFO)
Seblu's avatar
Seblu committed
		# parse command line
		args = parse_argv()
		# parse package list
		packages = ConfigParser()
Seblu's avatar
Seblu committed
		packages.read(args.config)
Seblu's avatar
Seblu committed
		# tell to systemd we are ready
		notify("READY=1\n")
		# while 42
Seblu's avatar
Seblu committed
		event_loop(packages, args.sleep)
Seblu's avatar
Seblu committed
	except KeyboardInterrupt:
		exit(ERR_ABORT)
	except Exception as exp:
		error(exp)
		if getLogger().getEffectiveLevel() != DEBUG:
			error("Unknown error. Please report it with --debug.")
		else:
			raise
		exit(ERR_UNKNOWN)

if __name__ == '__main__':
Seblu's avatar
Seblu committed
	main()