├── README.md ├── LICENSE ├── jobs.yml └── watcher.py /README.md: -------------------------------------------------------------------------------- 1 | See jobs.yml for proper configuration syntax 2 | 3 | Dependencies: python, python-pyinotify, python-yaml 4 | 5 | In Ubuntu (and Debian): 6 | 7 | sudo apt-get install python python-pyinotify python-yaml 8 | 9 | make sure watcher.py is marked as executable 10 | 11 | chmod +x watcher.py 12 | 13 | 14 | start the daemon with: 15 | 16 | ./watcher.py start 17 | 18 | 19 | stop it with: 20 | 21 | ./watcher.py stop 22 | 23 | 24 | restart it with: 25 | 26 | ./watcher.py restart 27 | 28 | 29 | The first time you start it (if you haven't done it yourself) it will 30 | create ~/.watcher and ~/.watcher/jobs.yml and then it will yell at 31 | you. You need to edit ~/.watcher/jobs.yml to setup folders to watch. 32 | You'll find a jobs.yml in the same directory as this README. Use that 33 | as an example. It should be pretty simple. 34 | 35 | If you edit ~/.watcher/jobs.yml you must restart the daemon for it to 36 | reload the configuration file. It'd make sense for me to set up 37 | watcher to watch the config file. That'll be coming soon. 38 | 39 | Problems? greggory.hz@gmail.com 40 | 41 | Have fun. 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Greggory Hernandez 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /jobs.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2010 Greggory Hernandez 2 | 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | # ---------------------------END COPYRIGHT-------------------------------------- 22 | 23 | # ---------------------------Eclusion feature added by Brunus --------------------------------- 24 | # Hi added an exclusion feature, something realy needed to monitor a website tree for exemple 25 | # brunus.v@gmail.com or @brunus_V on tweeter 26 | # --------------------------------------------------------------------------------------------- 27 | 28 | # This is a sample jobs file. Yours should go in ~/.watcher/jobs.yml 29 | # if you run watcher.py start, this file and folder will be created 30 | 31 | job1: 32 | # a generic label for a job. Currently not used make it whatever you want 33 | label: Watch /var/www for added or removed files 34 | 35 | # directory or file to watch. Probably should be abs path. 36 | watch: /var/www 37 | # directories or files to exclude 38 | exclude: ['/var/www/site1/cache', '/var/www/site2/cache'] 39 | 40 | # list of events to watch for. 41 | # supported events: 42 | # 'access' - File was accessed (read) (*) 43 | # 'atrribute_change' - Metadata changed (permissions, timestamps, extended attributes, etc.) (*) 44 | # 'write_close' - File opened for writing was closed (*) 45 | # 'nowrite_close' - File not opened for writing was closed (*) 46 | # 'create' - File/directory created in watched directory (*) 47 | # 'delete' - File/directory deleted from watched directory (*) 48 | # 'self_delete' - Watched file/directory was itself deleted 49 | # 'modify' - File was modified (*) 50 | # 'self_move' - Watched file/directory was itself moved 51 | # 'move_from' - File moved out of watched directory (*) 52 | # 'move_to' - File moved into watched directory (*) 53 | # 'open' - File was opened (*) 54 | # 'all' - Any of the above events are fired 55 | # 'move' - A combination of 'move_from' and 'move_to' 56 | # 'close' - A combination of 'write_close' and 'nowrite_close' 57 | # 58 | # When monitoring a directory, the events marked with an asterisk (*) above 59 | # can occur for files in the directory, in which case the name field in the 60 | # returned event data identifies the name of the file within the directory. 61 | events: ['create', 'move_from', 'move_to', 'delete', 'modify'] 62 | 63 | # TODO: 64 | # this currently isn't implemented, but this is where support will be added for: 65 | # IN_DONT_FOLLOW, IN_ONESHOT, IN_ONLYDIR and IN_NO_LOOP 66 | # There will be further documentation on these once they are implmented 67 | options: [] 68 | 69 | # if true, watcher will monitor directories recursively for changes 70 | recursive: true 71 | 72 | # the command to run. Can be any command. It's run as whatever user started watcher. 73 | # The following wildards may be used inside command specification: 74 | # $$ dollar sign 75 | # $watched watched filesystem path (see above) 76 | # $filename event-related file name 77 | # $tflags event flags (textually) 78 | # $nflags event flags (numerically) 79 | # $dest_file this will manage recursion better if included as the dest (especially when copying or similar) 80 | # if $dest_file was left out of the command below, Watcher won't properly 81 | # handle newly created directories when watching recursively. It's fine 82 | # to leave out when recursive is false or you won't be creating new 83 | # directories. 84 | # $src_path is only used in move_to and is the corresponding path from move_from 85 | # $src_rel_path [needs doc] 86 | # $datetime output date and time of the event, format is : Y-m-d H:M:S 87 | # command: cp -r $filename /home/user/Documents/$dest_file # $src_path 88 | command: echo $datetime $filename $tflags # $src_path 89 | -------------------------------------------------------------------------------- /watcher.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Copyright (c) 2010 Greggory Hernandez 3 | 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy 5 | # of this software and associated documentation files (the "Software"), to deal 6 | # in the Software without restriction, including without limitation the rights 7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | # copies of the Software, and to permit persons to whom the Software is 9 | # furnished to do so, subject to the following conditions: 10 | 11 | # The above copyright notice and this permission notice shall be included in 12 | # all copies or substantial portions of the Software. 13 | 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | # THE SOFTWARE. 21 | 22 | ### BEGIN INIT INFO 23 | # Provides: watcher.py 24 | # Required-Start: $remote_fs $syslog 25 | # Required-Stop: $remote_fs $syslog 26 | # Default-Start: 2 3 4 5 27 | # Default-Stop: 0 1 6 28 | # Short-Description: Monitor directories for file changes 29 | # Description: Monitor directories specified in /etc/watcher.ini for 30 | # changes using the Kernel's inotify mechanism and run 31 | # jobs when files or directories change 32 | ### END INIT INFO 33 | 34 | import sys, os, time, atexit 35 | from signal import SIGTERM 36 | import pyinotify 37 | import sys, os 38 | import datetime 39 | import subprocess 40 | from types import * 41 | from string import Template 42 | import configparser 43 | import argparse 44 | 45 | class Daemon: 46 | """ 47 | A generic daemon class 48 | 49 | Usage: subclass the Daemon class and override the run method 50 | """ 51 | def __init__(self, pidfile, stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'): 52 | self.stdin = stdin 53 | self.stdout = stdout 54 | self.stderr = stderr 55 | self.pidfile = pidfile 56 | 57 | def daemonize(self): 58 | """ 59 | do the UNIX double-fork magic, see Stevens' "Advanced Programming in the 60 | UNIX Environment" for details (ISBN 0201563177) 61 | http://www.erlenstar.demon.co.uk/unix/faq_2.html#SEC16 62 | """ 63 | try: 64 | pid = os.fork() 65 | if pid > 0: 66 | #exit first parent 67 | sys.exit(0) 68 | except OSError as e: 69 | sys.stderr.write("fork #1 failed: %d (%s)\n" % (e.errno, e.strerror)) 70 | sys.exit(1) 71 | 72 | # decouple from parent environment 73 | os.chdir("/") 74 | os.setsid() 75 | os.umask(0) 76 | 77 | # do second fork 78 | try: 79 | pid = os.fork() 80 | if pid > 0: 81 | # exit from second parent 82 | sys.exit(0) 83 | except OSError as e: 84 | sys.stderr.write("fork #2 failed: %d (%s)\n" % (e.errno, e.strerror)) 85 | sys.exit(1) 86 | 87 | #redirect standard file descriptors 88 | sys.stdout.flush() 89 | sys.stderr.flush() 90 | si = open(self.stdin, 'r') 91 | so = open(self.stdout, 'wb') 92 | se = open(self.stderr, 'wb', 0) 93 | os.dup2(si.fileno(), sys.stdin.fileno()) 94 | os.dup2(so.fileno(), sys.stdout.fileno()) 95 | os.dup2(se.fileno(), sys.stderr.fileno()) 96 | 97 | #write pid file 98 | atexit.register(self.delpid) 99 | pid = str(os.getpid()) 100 | open(self.pidfile, 'w+').write("%s\n" % pid) 101 | 102 | def delpid(self): 103 | os.remove(self.pidfile) 104 | 105 | def start(self): 106 | """ 107 | Start the daemon 108 | """ 109 | # Check for a pidfile to see if the daemon already runs 110 | try: 111 | pf = open(self.pidfile, 'r') 112 | pid = int(pf.read().strip()) 113 | pf.close() 114 | except IOError: 115 | pid = None 116 | 117 | if pid: 118 | message = "pidfile %s already exists. Daemon already running?\n" 119 | sys.stderr.write(message % self.pidfile) 120 | sys.exit(1) 121 | 122 | # Start the Daemon 123 | self.daemonize() 124 | self.run() 125 | 126 | def stop(self): 127 | """ 128 | Stop the daemon 129 | """ 130 | # get the pid from the pidfile 131 | try: 132 | pf = open(self.pidfile, 'r') 133 | pid = int(pf.read().strip()) 134 | pf.close() 135 | except IOError: 136 | pid = None 137 | 138 | if not pid: 139 | message = "pidfile %s does not exist. Daemon not running?\n" 140 | sys.stderr.write(message % self.pidfile) 141 | return # not an error in a restart 142 | 143 | # Try killing the daemon process 144 | try: 145 | while 1: 146 | os.kill(pid, SIGTERM) 147 | time.sleep(0.1) 148 | except OSError as err: 149 | err = str(err) 150 | if err.find("No such process") > 0: 151 | if os.path.exists(self.pidfile): 152 | os.remove(self.pidfile) 153 | else: 154 | print(str(err)) 155 | sys.exit(1) 156 | 157 | def restart(self): 158 | """ 159 | Restart the daemon 160 | """ 161 | self.stop() 162 | self.start() 163 | 164 | def status(self): 165 | try: 166 | pf = open(self.pidfile, 'r') 167 | pid = int(pf.read().strip()) 168 | pf.close() 169 | except IOError: 170 | pid = None 171 | 172 | if pid: 173 | print("service running") 174 | sys.exit(0) 175 | if not pid: 176 | print("service not running") 177 | sys.exit(3) 178 | 179 | def run(self): 180 | """ 181 | You should override this method when you subclass Daemon. It will be called after the process has been 182 | daemonized by start() or restart(). 183 | """ 184 | 185 | class EventHandler(pyinotify.ProcessEvent): 186 | def __init__(self, command): 187 | pyinotify.ProcessEvent.__init__(self) 188 | self.command = command 189 | 190 | # from http://stackoverflow.com/questions/35817/how-to-escape-os-system-calls-in-python 191 | def shellquote(self,s): 192 | s = str(s) 193 | return "'" + s.replace("'", "'\\''") + "'" 194 | 195 | def runCommand(self, event): 196 | t = Template(self.command) 197 | command = t.substitute(watched=self.shellquote(event.path), 198 | filename=self.shellquote(event.pathname), 199 | tflags=self.shellquote(event.maskname), 200 | nflags=self.shellquote(event.mask), 201 | cookie=self.shellquote(event.cookie if hasattr(event, "cookie") else 0)) 202 | try: 203 | os.system(command) 204 | except OSError as err: 205 | print("Failed to run command '%s' %s" % (command, str(err))) 206 | 207 | def process_IN_ACCESS(self, event): 208 | print("Access: ", event.pathname) 209 | self.runCommand(event) 210 | 211 | def process_IN_ATTRIB(self, event): 212 | print("Attrib: ", event.pathname) 213 | self.runCommand(event) 214 | 215 | def process_IN_CLOSE_WRITE(self, event): 216 | print("Close write: ", event.pathname) 217 | self.runCommand(event) 218 | 219 | def process_IN_CLOSE_NOWRITE(self, event): 220 | print("Close nowrite: ", event.pathname) 221 | self.runCommand(event) 222 | 223 | def process_IN_CREATE(self, event): 224 | print("Creating: ", event.pathname) 225 | self.runCommand(event) 226 | 227 | def process_IN_DELETE(self, event): 228 | print("Deleteing: ", event.pathname) 229 | self.runCommand(event) 230 | 231 | def process_IN_MODIFY(self, event): 232 | print("Modify: ", event.pathname) 233 | self.runCommand(event) 234 | 235 | def process_IN_MOVE_SELF(self, event): 236 | print("Move self: ", event.pathname) 237 | self.runCommand(event) 238 | 239 | def process_IN_MOVED_FROM(self, event): 240 | print("Moved from: ", event.pathname) 241 | self.runCommand(event) 242 | 243 | def process_IN_MOVED_TO(self, event): 244 | print("Moved to: ", event.pathname) 245 | self.runCommand(event) 246 | 247 | def process_IN_OPEN(self, event): 248 | print("Opened: ", event.pathname) 249 | self.runCommand(event) 250 | 251 | class WatcherDaemon(Daemon): 252 | 253 | def __init__(self, config): 254 | self.stdin = '/dev/null' 255 | self.stdout = config.get('DEFAULT','logfile') 256 | self.stderr = config.get('DEFAULT','logfile') 257 | self.pidfile = config.get('DEFAULT','pidfile') 258 | self.config = config 259 | 260 | def run(self): 261 | log('Daemon started') 262 | wdds = [] 263 | notifiers = [] 264 | 265 | # read jobs from config file 266 | for section in self.config.sections(): 267 | log(section + ": " + self.config.get(section,'watch')) 268 | # get the basic config info 269 | mask = self._parseMask(self.config.get(section,'events').split(',')) 270 | folder = self.config.get(section,'watch') 271 | recursive = self.config.getboolean(section,'recursive') 272 | autoadd = self.config.getboolean(section,'autoadd') 273 | excluded = self.config.get(section,'excluded') 274 | command = self.config.get(section,'command') 275 | 276 | # Exclude directories right away if 'excluded' regexp is set 277 | # Example https://github.com/seb-m/pyinotify/blob/master/python2/examples/exclude.py 278 | if excluded.strip() == '': # if 'excluded' is empty or whitespaces only 279 | excl = None 280 | else: 281 | excl = pyinotify.ExcludeFilter(excluded.split(',')) 282 | 283 | wm = pyinotify.WatchManager() 284 | handler = EventHandler(command) 285 | 286 | wdds.append(wm.add_watch(folder, mask, rec=recursive, auto_add=autoadd, exclude_filter=excl)) 287 | 288 | # BUT we need a new ThreadNotifier so I can specify a different 289 | # EventHandler instance for each job 290 | # this means that each job has its own thread as well (I think) 291 | notifiers.append(pyinotify.ThreadedNotifier(wm, handler)) 292 | 293 | # now we need to start ALL the notifiers. 294 | # TODO: load test this ... is having a thread for each a problem? 295 | for notifier in notifiers: 296 | notifier.start() 297 | 298 | 299 | def _parseMask(self, masks): 300 | ret = False; 301 | 302 | for mask in masks: 303 | mask = mask.strip() 304 | 305 | if 'access' == mask: 306 | ret = self._addMask(pyinotify.IN_ACCESS, ret) 307 | elif 'attribute_change' == mask: 308 | ret = self._addMask(pyinotify.IN_ATTRIB, ret) 309 | elif 'write_close' == mask: 310 | ret = self._addMask(pyinotify.IN_CLOSE_WRITE, ret) 311 | elif 'nowrite_close' == mask: 312 | ret = self._addMask(pyinotify.IN_CLOSE_NOWRITE, ret) 313 | elif 'create' == mask: 314 | ret = self._addMask(pyinotify.IN_CREATE, ret) 315 | elif 'delete' == mask: 316 | ret = self._addMask(pyinotify.IN_DELETE, ret) 317 | elif 'self_delete' == mask: 318 | ret = self._addMask(pyinotify.IN_DELETE_SELF, ret) 319 | elif 'modify' == mask: 320 | ret = self._addMask(pyinotify.IN_MODIFY, ret) 321 | elif 'self_move' == mask: 322 | ret = self._addMask(pyinotify.IN_MOVE_SELF, ret) 323 | elif 'move_from' == mask: 324 | ret = self._addMask(pyinotify.IN_MOVED_FROM, ret) 325 | elif 'move_to' == mask: 326 | ret = self._addMask(pyinotify.IN_MOVED_TO, ret) 327 | elif 'open' == mask: 328 | ret = self._addMask(pyinotify.IN_OPEN, ret) 329 | elif 'all' == mask: 330 | m = pyinotify.IN_ACCESS | pyinotify.IN_ATTRIB | pyinotify.IN_CLOSE_WRITE | \ 331 | pyinotify.IN_CLOSE_NOWRITE | pyinotify.IN_CREATE | pyinotify.IN_DELETE | \ 332 | pyinotify.IN_DELETE_SELF | pyinotify.IN_MODIFY | pyinotify.IN_MOVE_SELF | \ 333 | pyinotify.IN_MOVED_FROM | pyinotify.IN_MOVED_TO | pyinotify.IN_OPEN 334 | ret = self._addMask(m, ret) 335 | elif 'move' == mask: 336 | ret = self._addMask(pyinotify.IN_MOVED_FROM | pyinotify.IN_MOVED_TO, ret) 337 | elif 'close' == mask: 338 | ret = self._addMask(pyinotify.IN_CLOSE_WRITE | pyinotify.IN_CLOSE_NOWRITE, ret) 339 | 340 | return ret 341 | 342 | def _addMask(self, new_option, current_options): 343 | if not current_options: 344 | return new_option 345 | else: 346 | return current_options | new_option 347 | 348 | 349 | 350 | def log(msg): 351 | sys.stdout.write("%s %s\n" % ( str(datetime.datetime.now()), msg )) 352 | 353 | 354 | if __name__ == "__main__": 355 | # Parse commandline arguments 356 | parser = argparse.ArgumentParser( 357 | description='A daemon to monitor changes within specified directories and run commands on these changes.', 358 | ) 359 | parser.add_argument('-c','--config', 360 | action='store', 361 | help='Path to the config file (default: %(default)s)') 362 | parser.add_argument('command', 363 | action='store', 364 | choices=['start','stop','restart','status','debug'], 365 | help='What to do. Use debug to start in the foreground') 366 | args = parser.parse_args() 367 | 368 | # Parse the config file 369 | config = configparser.ConfigParser() 370 | if(args.config): 371 | confok = config.read(args.config) 372 | else: 373 | confok = config.read(['/etc/watcher.ini', os.path.expanduser('~/.watcher.ini')]); 374 | 375 | if(not confok): 376 | sys.stderr.write("Failed to read config file. Try -c parameter\n") 377 | sys.exit(4); 378 | 379 | # Initialize the daemon 380 | daemon = WatcherDaemon(config) 381 | 382 | # Execute the command 383 | if 'start' == args.command: 384 | daemon.start() 385 | elif 'stop' == args.command: 386 | daemon.stop() 387 | elif 'restart' == args.command: 388 | daemon.restart() 389 | elif 'status' == args.command: 390 | daemon.status() 391 | elif 'debug' == args.command: 392 | daemon.run() 393 | else: 394 | print("Unkown Command") 395 | sys.exit(2) 396 | sys.exit(0) 397 | --------------------------------------------------------------------------------