Loading aurbot +256 −285 Original line number Diff line number Diff line Loading @@ -45,7 +45,8 @@ from urllib.request import urlopen, Request from systemd.daemon import notify class Error(BaseException): """Error handling""" '''Error handling.''' ERR_USAGE = 1 ERR_ABORT = 2 ERR_CRITICAL = 3 Loading @@ -53,9 +54,8 @@ class Error(BaseException): class ABFormatter(Formatter): ''' Customer logging formater ''' '''Customer logging formater.''' def __init__(self, fmt="[%(levelname)s] %(msg)s"): super().__init__(fmt) Loading @@ -69,9 +69,7 @@ class ABFormatter(Formatter): class AURPackage(dict): ''' Abstract AUR package action ''' '''Abstract AUR package data.''' AUR_URL = 'https://aur.archlinux.org' USER_AGENT = "aurbot" Loading @@ -79,18 +77,17 @@ class AURPackage(dict): def __init__(self, name, timeout=None): super().__init__() self.name = name debug("getting %s aur infos" % self.name) url = "%s/rpc.php?type=info&arg=%s" % (self.AUR_URL, name) url_req = Request(url, headers={"User-Agent": self.USER_AGENT}) debug("Requesting url: %s (timeout: %s)" % (url, timeout)) debug(f"{name} Requesting url: {url} (timeout: {timeout}s)") 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"]) raise Exception(f"Unknown AUR Backend version: {d['version']}") if len(d["results"]) == 0: raise Exception("No such package: %s" % name) raise Exception(f"No such package: {name}") if d["results"]["PackageBase"] != name: raise Exception("No such base package: %s" % name) raise Exception(f"No such base package: {name}") self._info = d["results"] def __getattr__(self, name): Loading @@ -103,17 +100,15 @@ class AURPackage(dict): return "%s %s" % (self.name, self.version) def extract(self, path): ''' Extract aur source tarball inside a directory path ''' fo = urlopen('%s/%s' % (self.AUR_URL, self.urlpath)) '''Extract aur source tarball inside a directory path.''' fo = urlopen(f"{self.AUR_URL}/{self.urlpath}") tarball = tar(mode='r|*', fileobj=fo) tarball.extractall(path) fo.close() class LocalPackage(dict): '''Local package data abstraction''' '''Local package data.''' DEFAULT_DATA_DIR = "/var/lib/aurbot" Loading @@ -121,118 +116,90 @@ class LocalPackage(dict): super().__init__() self.name = name self.path = join(environ.get("AURBOT_DATADIR", self.DEFAULT_DATA_DIR), name) debug("local path is: %s" % self.path) debug(f"{name}: local path is: {self.path}") makedirs(self.path, exist_ok=True) @property def logdir(self): '''Return log files directory path''' '''Return log files directory path.''' logdir = join(self.path, "log") if not exists(logdir): mkdir(logdir) return logdir def getlastX(self, X, cast=int, default=0): '''Return saved value of X casted in cast''' '''Return saved value of X casted in cast.''' filepath = join(self.path, X) if not exists(filepath): return default try: return cast(open(filepath, "r").read()) except Exception as exp: debug("Failed to load %s: %s" % (X, exp)) debug(f"Failed to load {X}: {exp}") return default def setlastX(self, X, value, cast=int): '''Cast the value X in cast and save it to file named X''' '''Cast the value X in cast and save it to file named X.''' open(join(self.path, X), "w").write("%s" % cast(value)) # store the moment where the build was done locally # Store the moment where the build was done locally. lastbuild = property( lambda x: LocalPackage.getlastX(x, "lastbuild"), lambda x, y: LocalPackage.setlastX(x, "lastbuild", y) ) # store the aur lastmodified value of the last sucessful build # Store the aur lastmodified value of the last sucessful build. lastsuccess = property( lambda x: LocalPackage.getlastX(x, "lastsuccess"), lambda x, y: LocalPackage.setlastX(x, "lastsuccess", y) ) # store the aur lastmodified value of the last failed build # Store the aur lastmodified value of the last failed build. lastfailed = property( lambda x: LocalPackage.getlastX(x, "lastfailed"), lambda x, y: LocalPackage.setlastX(x, "lastfailed", y) ) # store the last time we check the aur # Store the last time we check the aur. lastchecked = property( lambda x: LocalPackage.getlastX(x, "lastchecked"), lambda x, y: LocalPackage.setlastX(x, "lastchecked", y) ) # store the last maintainer for the package # Store the last maintainer for the package. lastmaintainer = property( lambda x: LocalPackage.getlastX(x, "lastmaintainer", str, ""), lambda x, y: LocalPackage.setlastX(x, "lastmaintainer", y, str) ) class Aurbot(): ''' AUR Bot data and methods ''' class Package(): '''Package Meta Abstraction.''' DEFAULT_CHECK_INTERVAL = 86400 DEFAULT_CONFIG_FILE = "/etc/aurbot.conf" def __init__(self, path): ''' initialize the bot ''' self.init_config(abspath(path)) self.parse_config() def init_config(self, path=None): ''' default value for configured ''' if path is not None: self.config_path = path self.config_mtime = 0 self.config = ConfigParser() def parse_config(self): ''' parse the config file ''' # get the modification time of the config file try: mtime = stat(self.config_path).st_mtime except Exception as exp: self.init_config() debug("Unable to stat config file, empty one used: %s" % str(exp)) return # reload only when file has been modified if mtime > self.config_mtime: self.config_mtime = mtime self.config = ConfigParser() try: self.config.read(self.config_path) except Exception as exp: self.init_config() debug("Unable to parse config file, empty one used: %s" % str(exp)) info("Config file loaded %s" % self.config_path) def send_message(self, pkgconfig, msg): ''' Send message to an smtp server ''' info("Sending message to %s" % pkgconfig["notify"]) # load smtp info def __init__(self, pkgname, pkgconfig): self.name = pkgname self._config = pkgconfig self._local = LocalPackage(pkgname) # Print sugars. self.debug = lambda msg: debug(f"{self.name}: {msg}") self.info = lambda msg: info(f"{self.name}: {msg}") self.error = lambda msg: error(f"{self.name}: {msg}") self.warn = lambda msg: warning(f"{self.name}: {msg}") def send_message(self, msg): '''Send message to an smtp server.''' self.info(f"Sending message to {self._config['notify']}") # Load smtp info. try: smtp_host = pkgconfig["smtp_host"] smtp_port = pkgconfig["smtp_port"] smtp_login = pkgconfig.get("smtp_login", "") smtp_pass = pkgconfig.get("smtp_pass", "") smtp_security = pkgconfig.get("smtp_security", "") smtp_host = self._config["smtp_host"] smtp_port = self._config["smtp_port"] smtp_login = self._config.get("smtp_login", "") smtp_pass = self._config.get("smtp_pass", "") smtp_security = self._config.get("smtp_security", "") except: error("Unable to load smtp config") self.error("Unable to load smtp config") return # display message content when debug debug(msg) # prepare connection # Display message content when debug. self.debug(msg) # Prepare connection. con = SMTP_SSL() if smtp_security == "ssl" else SMTP() if getLogger().isEnabledFor(DEBUG): con.set_debuglevel(True) Loading @@ -243,245 +210,252 @@ class Aurbot(): con.starttls() if smtp_login != "" and smtp_pass != "": con.login(smtp_login, smtp_pass) # send it # Send it. con.send_message(msg) # gentleman quit # Gentleman quit. con.quit() except Exception as exp: error("Unable to send message via smtp: %s" % str(exp)) self.error(f"Unable to send message via smtp: {exp}") def send_build_report(self, pkgconfig, localpkg, aurpkg, status, logfile): ''' Send build notification ''' info("Send build report") # generate message def send_build_report(self, status, logfile): '''Send build notification.''' self.info("Send build report") # Generate message. msg = MIMEMultipart() msg["Subject"] = "Build %s for %s %s" % ( "successful" if status else "failure", localpkg.name, aurpkg.version) msg["From"] = pkgconfig.get("from", "Aurbot") msg["To"] = pkgconfig["notify"] msg["Subject"] = f"Build {status} for {self.name} {self._aur.version}" msg["From"] = self._config.get("from", "Aurbot") msg["To"] = self._config["notify"] msg["Date"] = formatdate(localtime=True) # attach logfile # Attach logfile. with open(logfile, "r") as fd: mt = MIMEText(fd.read()) msg.attach(mt) self.send_message(pkgconfig, msg) self.send_message(msg) def send_maintainer_report(self, pkgconfig, localpkg, aurpkg): ''' Send email to notify invalid maintainer ''' info("Send invalid maintainer report") # generate message def send_maintainer_report(self): '''Send email to notify of invalid maintainership.''' self.info("Send invalid maintainer report") # Generate message. msg = MIMEText( "Maintainer for package %s is invalid.\r\n" % localpkg.name + "Maintainer for package %s is invalid.\r\n" % self.name + "He has probably changed. Check if the new one is trustworthy.\r\n" "\r\n" "Configured maintainer is %s.\r\n" % pkgconfig.get("maintainer") + "AUR maintainer is %s.\r\n" % aurpkg.maintainer + "Configured maintainer is %s.\r\n" % self._config.get("maintainer") + "AUR maintainer is %s.\r\n" % self._aur.maintainer + "\r\n" "Your aurbot configuration need to be updated!\r\n") msg["Subject"] = "Invalid maintainer for %s" % localpkg.name msg["From"] = pkgconfig.get("from", "Aurbot") msg["To"] = pkgconfig["notify"] msg["Subject"] = "Invalid maintainer for %s" % self.name msg["From"] = self._config.get("from", "Aurbot") msg["To"] = self._config["notify"] msg["Date"] = formatdate(localtime=True) self.send_message(pkgconfig, msg) self.send_message(msg) def build(self, pkgconfig, localpkg, aurpkg): ''' Build a package ''' # register the build localpkg.lastbuild = time() # log files fp = join(localpkg.logdir, strftime("build-%Y-%m-%d-%H-%M-%S.log", localtime(time()))) debug("Build log file path: %s" % fp) # find build dir build_dir = TemporaryDirectory() debug("Build dir is %s" % build_dir.name) # extract tarball debug("Extracting aur tarball") aurpkg.extract(build_dir.name) with open(fp, "w") as fd: cwd = getcwd() try: chdir("%s/%s" % (build_dir.name, aurpkg.name)) # build info("Starting build command") debug(pkgconfig["build_cmd"]) fd.write("Build command: %s\n" % pkgconfig["build_cmd"]) fd.flush() def _run_command(self, name, cmd, log): '''Fancy run of command cmd and log output in file object log.''' self.info(f"Starting {name} command: {cmd}") log.write(f"Build command: {cmd}\n") log.flush() start_time = time() try: check_call(pkgconfig["build_cmd"], stdin=DEVNULL, stdout=fd, stderr=fd, shell=True, close_fds=True) check_call(cmd, stdin=DEVNULL, stdout=log, stderr=log, shell=True, close_fds=True) except Exception as exp: raise Exception("Build failure: %s" % str(exp)) from exp raise Exception(f"Build failure: {exp}") from exp end_time = time() info("Build duration: %.2fs" % (end_time - start_time)) fd.write("Build duration: %.2fs\n" % (end_time - start_time)) # commit if "commit_cmd" in pkgconfig: info("Starting commit command") debug(pkgconfig["commit_cmd"]) fd.write("Commit command: %s\n" % pkgconfig["commit_cmd"]) fd.flush() start_time = time() self.info(f"Build duration: {end_time - start_time:.2f}s") log.write(f"Build duration: {end_time - start_time:.2f}\n") def _build(self): '''Build a package.''' if "build_cmd" not in self._config: self.error("No build command.") return # Register the build start time. self._local.lastbuild = time() # Choose a log file name. logfn = join(self._local.logdir, strftime("build-%Y-%m-%d-%H-%M-%S.log", localtime(time()))) self.debug(f"Build log file path: {logfn}") # Make a temporary build directory. build_dir = TemporaryDirectory() # Extract the tarball inside it. self.debug("Extracting aur tarball in {build_dir.name}") self._aur.extract(build_dir.name) with open(logfn, "w") as logfo: cwd = getcwd() try: check_call(pkgconfig["commit_cmd"], stdin=DEVNULL, stdout=fd, stderr=fd, shell=True, close_fds=True) except Exception as exp: raise Exception("Commit failure: %s" % str(exp)) from exp end_time = time() info("Commit duration: %.2fs" % (end_time - start_time)) fd.write("Commit duration: %.2fs\n" % (end_time - start_time)) chdir(f"{build_dir.name}/{self.name}") # Execute build command. self._run_command("build", self._config['build_cmd'], logfo) # Execute commit command. if "commit_cmd" in self._config: self._run_command("commit", self._config['commit_cmd'], logfo) chdir(cwd) # we have to register after chdir in the original directory localpkg.lastsuccess = aurpkg.lastmodified status = True self._local.lastsuccess = self._aur.lastmodified status = "successful" except Exception as exp: error("Update failure: %s" % exp) self.error(f"Update failure: {exp}") chdir(cwd) # we have to register after chdir in the original directory localpkg.lastfailed = aurpkg.lastmodified status = False if "notify" in pkgconfig: self.send_build_report(pkgconfig, localpkg, aurpkg, status, fp) def update(self, pkgconfig, localpkg, aurpkg): ''' Update (build and commit) a package ''' debug("Updating %s" % aurpkg.name) # for security, if the maintainer is incorrect we fail debug("Configured maintainer: %s" % pkgconfig.get("maintainer")) debug("AUR maintainer: %s" % aurpkg.maintainer) debug("Last maintainer: %s" % localpkg.lastmaintainer) # str is required to handle no maintainer as None string if pkgconfig.get("maintainer") != str(aurpkg.maintainer): self._local.lastsuccess = self._aur.lastmodified status = "failure" if "notify" in self._config: self.send_build_report(status, logfn) def update(self): '''Update a package.''' # For security, if the maintainer is incorrect we fail. self.debug("Configured maintainer: %s" % self._config.get("maintainer")) self.debug("AUR maintainer: %s" % self._aur.maintainer) self.debug("Last maintainer: %s" % self._local.lastmaintainer) # str cast is required to handle no maintainer as None string if self._config.get("maintainer") == str(self._aur.maintainer): self._build() else: self.error(f"Invalid maintainer") # we notify by mail only once the maintainer is invalid if localpkg.lastmaintainer != str(aurpkg.maintainer): self.send_maintainer_report(pkgconfig, localpkg, aurpkg) localpkg.lastmaintainer = aurpkg.maintainer error("Invalid maintainer for package %s" % aurpkg.name) return localpkg.lastmaintainer = aurpkg.maintainer self.build(pkgconfig, localpkg, aurpkg) def start(self): ''' start the bot loop ''' while True: try: # reload package list self.parse_config() next_checks = set() for pkgname, pkgconfig in self.config.items(): if pkgname == "DEFAULT": continue info("[%s]" % pkgname) if "build_cmd" not in pkgconfig: error("build_cmd is missing in config file") continue localpkg = LocalPackage(pkgname) check_interval = pkgconfig.getint("check_interval", self.DEFAULT_CHECK_INTERVAL) debug("Check interval is %ss" % check_interval) check_delta = int(localpkg.lastchecked - time() + check_interval) debug("Check delta is %ss" % check_delta) if self._local.lastmaintainer != str(self._aur.maintainer): self.send_maintainer_report() self._local.lastmaintainer = self._aur.maintainer def check_delta(self): '''Return the time in seconds remaining before next check.''' check_interval = self._config.getint("check_interval", self.DEFAULT_CHECK_INTERVAL) self.debug(f"Check interval is {check_interval}s") check_delta = int(self._local.lastchecked - time() + check_interval) self.debug(f"Check delta is {check_delta}s") return check_delta def check(self): # compute check delta check_delta = self.check_delta() if check_delta > 0: # next check is in the future next_checks.add(check_delta) info("Next check is planned in %ss" % check_delta) continue next_checks.add(check_interval) self.info(f"Next check is planned in {check_delta}s") return check_delta # get remote data try: aurpkg = AURPackage(pkgname, pkgconfig.getint("timeout")) localpkg.lastchecked = int(time()) self._aur = AURPackage(self.name, self._config.getint("timeout")) self._local.lastchecked = int(time()) except Exception as exp: error("Unable to get AUR package info: %s" % exp) continue self.error(f"Unable to get AUR package info: {exp}") return # few debug printing debug("AUR last modified: %s" % aurpkg.lastmodified) debug("Local last success lastmodified: %s" % localpkg.lastbuild) debug("Local last failed lastmodified: %s" % localpkg.lastfailed) debug("Local last build time: %s" % localpkg.lastbuild) self.debug(f"AUR last modified: {self._aur.lastmodified}") self.debug(f"Local last success lastmodified: {self._local.lastbuild}") self.debug(f"Local last failed lastmodified: {self._local.lastfailed}") self.debug(f"Local last build time: {self._local.lastbuild}") # check if package need to be updated if localpkg.lastsuccess >= aurpkg.lastmodified: if "force" in pkgconfig: info("Up to date, but force value is present.") if pkgconfig["force"].isdigit() is False: warning("Invalid force value, ignore it") continue if self._local.lastsuccess >= self._aur.lastmodified: if "force" in self._config: self.info("Up to date, but force value is present.") if self._config["force"].isdigit() is False: self.warn("Invalid force value, ignore it") return # 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(pkgconfig["force"]) debug("Force is: %ss" % force) force_delta = localpkg.lastbuild - now + force debug("Force Delta is: %ss" % force_delta) force = int(self._config["force"]) self.debug(f"Force is: {force}s") force_delta = self._local.lastbuild - now + force self.debug(f"Force Delta is: {force_delta}s") if force_delta < 0: info("Forced update") self.update(pkgconfig, localpkg, aurpkg) self.info("Forced update") self.update() else: info("Next forced update in %ss" % force_delta) self.info(f"Next forced update in {force_delta}s") else: info("Up to date, nothing to do.") elif localpkg.lastfailed >= aurpkg.lastmodified: warning("Last build has failed, skipping. Remove lastfailed file to retry.") self.info("Up to date, nothing to do.") elif self._local.lastfailed >= self._aur.lastmodified: self.warn("Last build has failed, skipping. Remove lastfailed file to retry.") else: info("New version available: %s" % aurpkg.version) self.update(pkgconfig, localpkg, aurpkg) # sleep until next check # len(next_checks) is 0 when there is no package configured timeout = min(next_checks) if len(next_checks) > 0 else self.DEFAULT_CHECK_INTERVAL debug("waiting for %ds" % timeout) sleep(timeout) except InterruptedError: pass self.info(f"New version available: {self._aur.version}") self.update() # return updated check_delta return self.check_delta() class Robot(): '''AUR Package Builder Robot.''' DEFAULT_CONFIG_FILE = "/etc/aurbot.conf" @staticmethod def sighup_handler(signum, frame): '''Handler for HUP signal (a.k.a reload)''' info("SIGHUP received") # since python 3.5 we need to raise an exception to prevent python to EINTR # see https://www.python.org/dev/peps/pep-0475/ # Since python 3.5 we need to raise an exception to prevent python to EINTR, # see https://www.python.org/dev/peps/pep-0475/. raise InterruptedError() def parse_argv(): def __init__(self): # Set logger config. hdlr = StreamHandler() hdlr.setFormatter(ABFormatter()) getLogger().addHandler(hdlr) # Early debugging mode. getLogger().setLevel(DEBUG if "AURBOT_DEBUG" in environ else INFO) # Do not run as root. if geteuid() == 0 and "AURBOT_RUN_AS_ROOT" not in environ: raise Error("Do not run as root") # Use sighup to unblock sleep syscall. signal(SIGHUP, self.sighup_handler) # Parse command line. self._parse_argv() # Late debugging mode. if self._args.debug: getLogger().setLevel(DEBUG) # Load config. self._parse_config() # Tell to systemd we are ready. notify("READY=1\n") def _parse_argv(self): '''Parse command line arguments''' # load parser # Load parser. parser = ArgumentParser() parser.add_argument("-c", "--config", help="config file path", default=environ.get("AURBOT_CONFIG", self.DEFAULT_CONFIG_FILE)) parser.add_argument("-d", "--debug", action="store_true", help="debug mode") parser.epilog = "You could set $XDG_DATA_HOME to change the path of the local package cache." # parse it! args = parser.parse_args() # set global debug mode if args.debug: getLogger().setLevel(DEBUG) return args # Parse it! self._args = parser.parse_args() def main(): '''Program entry point''' def _parse_config(self): '''Parse the config file.''' try: # set logger config hdlr = StreamHandler() hdlr.setFormatter(ABFormatter()) getLogger().addHandler(hdlr) # Early debugging mode getLogger().setLevel(DEBUG if "AURBOT_DEBUG" in environ else INFO) # do not run as root if geteuid() == 0: raise Error("Do not run as root") # use sighup to unblock sleep syscall signal(SIGHUP, sighup_handler) # parse command line args = parse_argv() # create the bot object bot = Aurbot(args.config) # tell to systemd we are ready notify("READY=1\n") # start the bot bot.start() # Get the modification time of the config file. mtime = stat(self._args.config).st_mtime # Reload only when file has been modified. if not hasattr(self, "_config") or mtime > self._config_mtime: self._config = ConfigParser() self._config.read(self._args.config) self._config_mtime = mtime info(f"Config file loaded: {self._args.config}") if len(self._config.sections()) == 0: raise Error("Empty configuration") except Exception as exp: raise Error(f"Unable to load config file: {exp}") def start(self): '''Start the robot rock.''' while True: try: # Check for config update. self._parse_config() next_checks = set() for pkgname in self._config.sections(): pkg = Package(pkgname, self._config[pkgname]) next_checks.add(pkg.check()) # Sleep until next check. timeout = min(next_checks) debug(f"Waiting for {timeout}s") sleep(timeout) except InterruptedError: pass if __name__ == '__main__': try: Robot().start() except KeyboardInterrupt: exit(Error.ERR_ABORT) except Error as exp: Loading @@ -494,6 +468,3 @@ def main(): else: raise exit(Error.ERR_UNKNOWN) if __name__ == '__main__': main() Loading
aurbot +256 −285 Original line number Diff line number Diff line Loading @@ -45,7 +45,8 @@ from urllib.request import urlopen, Request from systemd.daemon import notify class Error(BaseException): """Error handling""" '''Error handling.''' ERR_USAGE = 1 ERR_ABORT = 2 ERR_CRITICAL = 3 Loading @@ -53,9 +54,8 @@ class Error(BaseException): class ABFormatter(Formatter): ''' Customer logging formater ''' '''Customer logging formater.''' def __init__(self, fmt="[%(levelname)s] %(msg)s"): super().__init__(fmt) Loading @@ -69,9 +69,7 @@ class ABFormatter(Formatter): class AURPackage(dict): ''' Abstract AUR package action ''' '''Abstract AUR package data.''' AUR_URL = 'https://aur.archlinux.org' USER_AGENT = "aurbot" Loading @@ -79,18 +77,17 @@ class AURPackage(dict): def __init__(self, name, timeout=None): super().__init__() self.name = name debug("getting %s aur infos" % self.name) url = "%s/rpc.php?type=info&arg=%s" % (self.AUR_URL, name) url_req = Request(url, headers={"User-Agent": self.USER_AGENT}) debug("Requesting url: %s (timeout: %s)" % (url, timeout)) debug(f"{name} Requesting url: {url} (timeout: {timeout}s)") 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"]) raise Exception(f"Unknown AUR Backend version: {d['version']}") if len(d["results"]) == 0: raise Exception("No such package: %s" % name) raise Exception(f"No such package: {name}") if d["results"]["PackageBase"] != name: raise Exception("No such base package: %s" % name) raise Exception(f"No such base package: {name}") self._info = d["results"] def __getattr__(self, name): Loading @@ -103,17 +100,15 @@ class AURPackage(dict): return "%s %s" % (self.name, self.version) def extract(self, path): ''' Extract aur source tarball inside a directory path ''' fo = urlopen('%s/%s' % (self.AUR_URL, self.urlpath)) '''Extract aur source tarball inside a directory path.''' fo = urlopen(f"{self.AUR_URL}/{self.urlpath}") tarball = tar(mode='r|*', fileobj=fo) tarball.extractall(path) fo.close() class LocalPackage(dict): '''Local package data abstraction''' '''Local package data.''' DEFAULT_DATA_DIR = "/var/lib/aurbot" Loading @@ -121,118 +116,90 @@ class LocalPackage(dict): super().__init__() self.name = name self.path = join(environ.get("AURBOT_DATADIR", self.DEFAULT_DATA_DIR), name) debug("local path is: %s" % self.path) debug(f"{name}: local path is: {self.path}") makedirs(self.path, exist_ok=True) @property def logdir(self): '''Return log files directory path''' '''Return log files directory path.''' logdir = join(self.path, "log") if not exists(logdir): mkdir(logdir) return logdir def getlastX(self, X, cast=int, default=0): '''Return saved value of X casted in cast''' '''Return saved value of X casted in cast.''' filepath = join(self.path, X) if not exists(filepath): return default try: return cast(open(filepath, "r").read()) except Exception as exp: debug("Failed to load %s: %s" % (X, exp)) debug(f"Failed to load {X}: {exp}") return default def setlastX(self, X, value, cast=int): '''Cast the value X in cast and save it to file named X''' '''Cast the value X in cast and save it to file named X.''' open(join(self.path, X), "w").write("%s" % cast(value)) # store the moment where the build was done locally # Store the moment where the build was done locally. lastbuild = property( lambda x: LocalPackage.getlastX(x, "lastbuild"), lambda x, y: LocalPackage.setlastX(x, "lastbuild", y) ) # store the aur lastmodified value of the last sucessful build # Store the aur lastmodified value of the last sucessful build. lastsuccess = property( lambda x: LocalPackage.getlastX(x, "lastsuccess"), lambda x, y: LocalPackage.setlastX(x, "lastsuccess", y) ) # store the aur lastmodified value of the last failed build # Store the aur lastmodified value of the last failed build. lastfailed = property( lambda x: LocalPackage.getlastX(x, "lastfailed"), lambda x, y: LocalPackage.setlastX(x, "lastfailed", y) ) # store the last time we check the aur # Store the last time we check the aur. lastchecked = property( lambda x: LocalPackage.getlastX(x, "lastchecked"), lambda x, y: LocalPackage.setlastX(x, "lastchecked", y) ) # store the last maintainer for the package # Store the last maintainer for the package. lastmaintainer = property( lambda x: LocalPackage.getlastX(x, "lastmaintainer", str, ""), lambda x, y: LocalPackage.setlastX(x, "lastmaintainer", y, str) ) class Aurbot(): ''' AUR Bot data and methods ''' class Package(): '''Package Meta Abstraction.''' DEFAULT_CHECK_INTERVAL = 86400 DEFAULT_CONFIG_FILE = "/etc/aurbot.conf" def __init__(self, path): ''' initialize the bot ''' self.init_config(abspath(path)) self.parse_config() def init_config(self, path=None): ''' default value for configured ''' if path is not None: self.config_path = path self.config_mtime = 0 self.config = ConfigParser() def parse_config(self): ''' parse the config file ''' # get the modification time of the config file try: mtime = stat(self.config_path).st_mtime except Exception as exp: self.init_config() debug("Unable to stat config file, empty one used: %s" % str(exp)) return # reload only when file has been modified if mtime > self.config_mtime: self.config_mtime = mtime self.config = ConfigParser() try: self.config.read(self.config_path) except Exception as exp: self.init_config() debug("Unable to parse config file, empty one used: %s" % str(exp)) info("Config file loaded %s" % self.config_path) def send_message(self, pkgconfig, msg): ''' Send message to an smtp server ''' info("Sending message to %s" % pkgconfig["notify"]) # load smtp info def __init__(self, pkgname, pkgconfig): self.name = pkgname self._config = pkgconfig self._local = LocalPackage(pkgname) # Print sugars. self.debug = lambda msg: debug(f"{self.name}: {msg}") self.info = lambda msg: info(f"{self.name}: {msg}") self.error = lambda msg: error(f"{self.name}: {msg}") self.warn = lambda msg: warning(f"{self.name}: {msg}") def send_message(self, msg): '''Send message to an smtp server.''' self.info(f"Sending message to {self._config['notify']}") # Load smtp info. try: smtp_host = pkgconfig["smtp_host"] smtp_port = pkgconfig["smtp_port"] smtp_login = pkgconfig.get("smtp_login", "") smtp_pass = pkgconfig.get("smtp_pass", "") smtp_security = pkgconfig.get("smtp_security", "") smtp_host = self._config["smtp_host"] smtp_port = self._config["smtp_port"] smtp_login = self._config.get("smtp_login", "") smtp_pass = self._config.get("smtp_pass", "") smtp_security = self._config.get("smtp_security", "") except: error("Unable to load smtp config") self.error("Unable to load smtp config") return # display message content when debug debug(msg) # prepare connection # Display message content when debug. self.debug(msg) # Prepare connection. con = SMTP_SSL() if smtp_security == "ssl" else SMTP() if getLogger().isEnabledFor(DEBUG): con.set_debuglevel(True) Loading @@ -243,245 +210,252 @@ class Aurbot(): con.starttls() if smtp_login != "" and smtp_pass != "": con.login(smtp_login, smtp_pass) # send it # Send it. con.send_message(msg) # gentleman quit # Gentleman quit. con.quit() except Exception as exp: error("Unable to send message via smtp: %s" % str(exp)) self.error(f"Unable to send message via smtp: {exp}") def send_build_report(self, pkgconfig, localpkg, aurpkg, status, logfile): ''' Send build notification ''' info("Send build report") # generate message def send_build_report(self, status, logfile): '''Send build notification.''' self.info("Send build report") # Generate message. msg = MIMEMultipart() msg["Subject"] = "Build %s for %s %s" % ( "successful" if status else "failure", localpkg.name, aurpkg.version) msg["From"] = pkgconfig.get("from", "Aurbot") msg["To"] = pkgconfig["notify"] msg["Subject"] = f"Build {status} for {self.name} {self._aur.version}" msg["From"] = self._config.get("from", "Aurbot") msg["To"] = self._config["notify"] msg["Date"] = formatdate(localtime=True) # attach logfile # Attach logfile. with open(logfile, "r") as fd: mt = MIMEText(fd.read()) msg.attach(mt) self.send_message(pkgconfig, msg) self.send_message(msg) def send_maintainer_report(self, pkgconfig, localpkg, aurpkg): ''' Send email to notify invalid maintainer ''' info("Send invalid maintainer report") # generate message def send_maintainer_report(self): '''Send email to notify of invalid maintainership.''' self.info("Send invalid maintainer report") # Generate message. msg = MIMEText( "Maintainer for package %s is invalid.\r\n" % localpkg.name + "Maintainer for package %s is invalid.\r\n" % self.name + "He has probably changed. Check if the new one is trustworthy.\r\n" "\r\n" "Configured maintainer is %s.\r\n" % pkgconfig.get("maintainer") + "AUR maintainer is %s.\r\n" % aurpkg.maintainer + "Configured maintainer is %s.\r\n" % self._config.get("maintainer") + "AUR maintainer is %s.\r\n" % self._aur.maintainer + "\r\n" "Your aurbot configuration need to be updated!\r\n") msg["Subject"] = "Invalid maintainer for %s" % localpkg.name msg["From"] = pkgconfig.get("from", "Aurbot") msg["To"] = pkgconfig["notify"] msg["Subject"] = "Invalid maintainer for %s" % self.name msg["From"] = self._config.get("from", "Aurbot") msg["To"] = self._config["notify"] msg["Date"] = formatdate(localtime=True) self.send_message(pkgconfig, msg) self.send_message(msg) def build(self, pkgconfig, localpkg, aurpkg): ''' Build a package ''' # register the build localpkg.lastbuild = time() # log files fp = join(localpkg.logdir, strftime("build-%Y-%m-%d-%H-%M-%S.log", localtime(time()))) debug("Build log file path: %s" % fp) # find build dir build_dir = TemporaryDirectory() debug("Build dir is %s" % build_dir.name) # extract tarball debug("Extracting aur tarball") aurpkg.extract(build_dir.name) with open(fp, "w") as fd: cwd = getcwd() try: chdir("%s/%s" % (build_dir.name, aurpkg.name)) # build info("Starting build command") debug(pkgconfig["build_cmd"]) fd.write("Build command: %s\n" % pkgconfig["build_cmd"]) fd.flush() def _run_command(self, name, cmd, log): '''Fancy run of command cmd and log output in file object log.''' self.info(f"Starting {name} command: {cmd}") log.write(f"Build command: {cmd}\n") log.flush() start_time = time() try: check_call(pkgconfig["build_cmd"], stdin=DEVNULL, stdout=fd, stderr=fd, shell=True, close_fds=True) check_call(cmd, stdin=DEVNULL, stdout=log, stderr=log, shell=True, close_fds=True) except Exception as exp: raise Exception("Build failure: %s" % str(exp)) from exp raise Exception(f"Build failure: {exp}") from exp end_time = time() info("Build duration: %.2fs" % (end_time - start_time)) fd.write("Build duration: %.2fs\n" % (end_time - start_time)) # commit if "commit_cmd" in pkgconfig: info("Starting commit command") debug(pkgconfig["commit_cmd"]) fd.write("Commit command: %s\n" % pkgconfig["commit_cmd"]) fd.flush() start_time = time() self.info(f"Build duration: {end_time - start_time:.2f}s") log.write(f"Build duration: {end_time - start_time:.2f}\n") def _build(self): '''Build a package.''' if "build_cmd" not in self._config: self.error("No build command.") return # Register the build start time. self._local.lastbuild = time() # Choose a log file name. logfn = join(self._local.logdir, strftime("build-%Y-%m-%d-%H-%M-%S.log", localtime(time()))) self.debug(f"Build log file path: {logfn}") # Make a temporary build directory. build_dir = TemporaryDirectory() # Extract the tarball inside it. self.debug("Extracting aur tarball in {build_dir.name}") self._aur.extract(build_dir.name) with open(logfn, "w") as logfo: cwd = getcwd() try: check_call(pkgconfig["commit_cmd"], stdin=DEVNULL, stdout=fd, stderr=fd, shell=True, close_fds=True) except Exception as exp: raise Exception("Commit failure: %s" % str(exp)) from exp end_time = time() info("Commit duration: %.2fs" % (end_time - start_time)) fd.write("Commit duration: %.2fs\n" % (end_time - start_time)) chdir(f"{build_dir.name}/{self.name}") # Execute build command. self._run_command("build", self._config['build_cmd'], logfo) # Execute commit command. if "commit_cmd" in self._config: self._run_command("commit", self._config['commit_cmd'], logfo) chdir(cwd) # we have to register after chdir in the original directory localpkg.lastsuccess = aurpkg.lastmodified status = True self._local.lastsuccess = self._aur.lastmodified status = "successful" except Exception as exp: error("Update failure: %s" % exp) self.error(f"Update failure: {exp}") chdir(cwd) # we have to register after chdir in the original directory localpkg.lastfailed = aurpkg.lastmodified status = False if "notify" in pkgconfig: self.send_build_report(pkgconfig, localpkg, aurpkg, status, fp) def update(self, pkgconfig, localpkg, aurpkg): ''' Update (build and commit) a package ''' debug("Updating %s" % aurpkg.name) # for security, if the maintainer is incorrect we fail debug("Configured maintainer: %s" % pkgconfig.get("maintainer")) debug("AUR maintainer: %s" % aurpkg.maintainer) debug("Last maintainer: %s" % localpkg.lastmaintainer) # str is required to handle no maintainer as None string if pkgconfig.get("maintainer") != str(aurpkg.maintainer): self._local.lastsuccess = self._aur.lastmodified status = "failure" if "notify" in self._config: self.send_build_report(status, logfn) def update(self): '''Update a package.''' # For security, if the maintainer is incorrect we fail. self.debug("Configured maintainer: %s" % self._config.get("maintainer")) self.debug("AUR maintainer: %s" % self._aur.maintainer) self.debug("Last maintainer: %s" % self._local.lastmaintainer) # str cast is required to handle no maintainer as None string if self._config.get("maintainer") == str(self._aur.maintainer): self._build() else: self.error(f"Invalid maintainer") # we notify by mail only once the maintainer is invalid if localpkg.lastmaintainer != str(aurpkg.maintainer): self.send_maintainer_report(pkgconfig, localpkg, aurpkg) localpkg.lastmaintainer = aurpkg.maintainer error("Invalid maintainer for package %s" % aurpkg.name) return localpkg.lastmaintainer = aurpkg.maintainer self.build(pkgconfig, localpkg, aurpkg) def start(self): ''' start the bot loop ''' while True: try: # reload package list self.parse_config() next_checks = set() for pkgname, pkgconfig in self.config.items(): if pkgname == "DEFAULT": continue info("[%s]" % pkgname) if "build_cmd" not in pkgconfig: error("build_cmd is missing in config file") continue localpkg = LocalPackage(pkgname) check_interval = pkgconfig.getint("check_interval", self.DEFAULT_CHECK_INTERVAL) debug("Check interval is %ss" % check_interval) check_delta = int(localpkg.lastchecked - time() + check_interval) debug("Check delta is %ss" % check_delta) if self._local.lastmaintainer != str(self._aur.maintainer): self.send_maintainer_report() self._local.lastmaintainer = self._aur.maintainer def check_delta(self): '''Return the time in seconds remaining before next check.''' check_interval = self._config.getint("check_interval", self.DEFAULT_CHECK_INTERVAL) self.debug(f"Check interval is {check_interval}s") check_delta = int(self._local.lastchecked - time() + check_interval) self.debug(f"Check delta is {check_delta}s") return check_delta def check(self): # compute check delta check_delta = self.check_delta() if check_delta > 0: # next check is in the future next_checks.add(check_delta) info("Next check is planned in %ss" % check_delta) continue next_checks.add(check_interval) self.info(f"Next check is planned in {check_delta}s") return check_delta # get remote data try: aurpkg = AURPackage(pkgname, pkgconfig.getint("timeout")) localpkg.lastchecked = int(time()) self._aur = AURPackage(self.name, self._config.getint("timeout")) self._local.lastchecked = int(time()) except Exception as exp: error("Unable to get AUR package info: %s" % exp) continue self.error(f"Unable to get AUR package info: {exp}") return # few debug printing debug("AUR last modified: %s" % aurpkg.lastmodified) debug("Local last success lastmodified: %s" % localpkg.lastbuild) debug("Local last failed lastmodified: %s" % localpkg.lastfailed) debug("Local last build time: %s" % localpkg.lastbuild) self.debug(f"AUR last modified: {self._aur.lastmodified}") self.debug(f"Local last success lastmodified: {self._local.lastbuild}") self.debug(f"Local last failed lastmodified: {self._local.lastfailed}") self.debug(f"Local last build time: {self._local.lastbuild}") # check if package need to be updated if localpkg.lastsuccess >= aurpkg.lastmodified: if "force" in pkgconfig: info("Up to date, but force value is present.") if pkgconfig["force"].isdigit() is False: warning("Invalid force value, ignore it") continue if self._local.lastsuccess >= self._aur.lastmodified: if "force" in self._config: self.info("Up to date, but force value is present.") if self._config["force"].isdigit() is False: self.warn("Invalid force value, ignore it") return # 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(pkgconfig["force"]) debug("Force is: %ss" % force) force_delta = localpkg.lastbuild - now + force debug("Force Delta is: %ss" % force_delta) force = int(self._config["force"]) self.debug(f"Force is: {force}s") force_delta = self._local.lastbuild - now + force self.debug(f"Force Delta is: {force_delta}s") if force_delta < 0: info("Forced update") self.update(pkgconfig, localpkg, aurpkg) self.info("Forced update") self.update() else: info("Next forced update in %ss" % force_delta) self.info(f"Next forced update in {force_delta}s") else: info("Up to date, nothing to do.") elif localpkg.lastfailed >= aurpkg.lastmodified: warning("Last build has failed, skipping. Remove lastfailed file to retry.") self.info("Up to date, nothing to do.") elif self._local.lastfailed >= self._aur.lastmodified: self.warn("Last build has failed, skipping. Remove lastfailed file to retry.") else: info("New version available: %s" % aurpkg.version) self.update(pkgconfig, localpkg, aurpkg) # sleep until next check # len(next_checks) is 0 when there is no package configured timeout = min(next_checks) if len(next_checks) > 0 else self.DEFAULT_CHECK_INTERVAL debug("waiting for %ds" % timeout) sleep(timeout) except InterruptedError: pass self.info(f"New version available: {self._aur.version}") self.update() # return updated check_delta return self.check_delta() class Robot(): '''AUR Package Builder Robot.''' DEFAULT_CONFIG_FILE = "/etc/aurbot.conf" @staticmethod def sighup_handler(signum, frame): '''Handler for HUP signal (a.k.a reload)''' info("SIGHUP received") # since python 3.5 we need to raise an exception to prevent python to EINTR # see https://www.python.org/dev/peps/pep-0475/ # Since python 3.5 we need to raise an exception to prevent python to EINTR, # see https://www.python.org/dev/peps/pep-0475/. raise InterruptedError() def parse_argv(): def __init__(self): # Set logger config. hdlr = StreamHandler() hdlr.setFormatter(ABFormatter()) getLogger().addHandler(hdlr) # Early debugging mode. getLogger().setLevel(DEBUG if "AURBOT_DEBUG" in environ else INFO) # Do not run as root. if geteuid() == 0 and "AURBOT_RUN_AS_ROOT" not in environ: raise Error("Do not run as root") # Use sighup to unblock sleep syscall. signal(SIGHUP, self.sighup_handler) # Parse command line. self._parse_argv() # Late debugging mode. if self._args.debug: getLogger().setLevel(DEBUG) # Load config. self._parse_config() # Tell to systemd we are ready. notify("READY=1\n") def _parse_argv(self): '''Parse command line arguments''' # load parser # Load parser. parser = ArgumentParser() parser.add_argument("-c", "--config", help="config file path", default=environ.get("AURBOT_CONFIG", self.DEFAULT_CONFIG_FILE)) parser.add_argument("-d", "--debug", action="store_true", help="debug mode") parser.epilog = "You could set $XDG_DATA_HOME to change the path of the local package cache." # parse it! args = parser.parse_args() # set global debug mode if args.debug: getLogger().setLevel(DEBUG) return args # Parse it! self._args = parser.parse_args() def main(): '''Program entry point''' def _parse_config(self): '''Parse the config file.''' try: # set logger config hdlr = StreamHandler() hdlr.setFormatter(ABFormatter()) getLogger().addHandler(hdlr) # Early debugging mode getLogger().setLevel(DEBUG if "AURBOT_DEBUG" in environ else INFO) # do not run as root if geteuid() == 0: raise Error("Do not run as root") # use sighup to unblock sleep syscall signal(SIGHUP, sighup_handler) # parse command line args = parse_argv() # create the bot object bot = Aurbot(args.config) # tell to systemd we are ready notify("READY=1\n") # start the bot bot.start() # Get the modification time of the config file. mtime = stat(self._args.config).st_mtime # Reload only when file has been modified. if not hasattr(self, "_config") or mtime > self._config_mtime: self._config = ConfigParser() self._config.read(self._args.config) self._config_mtime = mtime info(f"Config file loaded: {self._args.config}") if len(self._config.sections()) == 0: raise Error("Empty configuration") except Exception as exp: raise Error(f"Unable to load config file: {exp}") def start(self): '''Start the robot rock.''' while True: try: # Check for config update. self._parse_config() next_checks = set() for pkgname in self._config.sections(): pkg = Package(pkgname, self._config[pkgname]) next_checks.add(pkg.check()) # Sleep until next check. timeout = min(next_checks) debug(f"Waiting for {timeout}s") sleep(timeout) except InterruptedError: pass if __name__ == '__main__': try: Robot().start() except KeyboardInterrupt: exit(Error.ERR_ABORT) except Error as exp: Loading @@ -494,6 +468,3 @@ def main(): else: raise exit(Error.ERR_UNKNOWN) if __name__ == '__main__': main()