Add some more examples and a convenience method in config.py for
[pyutils.git] / examples / cron / cron.py
1 #!/usr/bin/env python3
2
3 """Wrapper that adds exclusive locks, timeouts, timestamp accounting,
4 max frequency, logging, etc... to running cron jobs.
5 """
6
7 import datetime
8 import logging
9 import os
10 import sys
11 from typing import Optional
12
13 from pyutils import bootstrap, config, exec_utils, stopwatch
14 from pyutils.datetimez import datetime_utils
15 from pyutils.files import file_utils, lockfile
16
17 logger = logging.getLogger(__name__)
18
19 cfg = config.add_commandline_args(
20     f'Python Cron Runner ({__file__})',
21     'Wrapper for cron commands with locking, timeouts, and accounting.',
22 )
23 cfg.add_argument(
24     '--lockfile',
25     default=None,
26     metavar='LOCKFILE_PATH',
27     help='Path to the lockfile to use to ensure that two instances of a command do not execute contemporaneously.',
28 )
29 cfg.add_argument(
30     '--timeout',
31     type=str,
32     metavar='TIMEOUT',
33     default=None,
34     help='Maximum time for lock acquisition + command execution.  Undecorated for seconds but "3m" or "1h 15m" work too.',
35 )
36 cfg.add_argument(
37     '--timestamp',
38     type=str,
39     metavar='TIMESTAMP_FILE',
40     default=None,
41     help='The /timestamp/TIMESTAMP_FILE file tracking the work being done; files\' mtimes will be set to the last successful run of a command for accounting purposes.',
42 )
43 cfg.add_argument(
44     '--max_frequency',
45     type=str,
46     metavar='FREQUENCY',
47     default=None,
48     help='The maximum frequency with which to do this work; even if the wrapper is invoked more often than this it will not run the command.  Requires --timestamp.  Undecorated for seconds but "3h" or "1h 15m" work too.',
49 )
50 cfg.add_argument(
51     '--command',
52     nargs='*',
53     required=True,
54     type=str,
55     metavar='COMMANDLINE',
56     help='The commandline to run under a lock.',
57 )
58 config.overwrite_argparse_epilog(
59     """
60 cron.py's exit value:
61
62    -1000 = some internal error occurred (see exception log).
63        0 = we exited early due to not enough time passage since the last
64            invocation of --command.
65     1000 = we could not obtain the lockfile; someone else owns it.
66  else = if the --command was run successfully, cron.py will exit with
67         the same code that the subcommand exited with.
68 """
69 )
70
71
72 def run_command(timeout: Optional[int], timestamp_file: Optional[str]) -> int:
73     """Run cron command"""
74     cmd = ' '.join(config.config['command'])
75     logger.info('cron cmd = "%s"', cmd)
76     logger.debug('shell environment:')
77     for var in os.environ:
78         val = os.environ[var]
79         logger.debug('%s = %s', var, val)
80     logger.debug('____ (↓↓↓ output from the subprocess appears below here ↓↓↓) ____')
81     try:
82         with stopwatch.Timer() as t:
83             ret = exec_utils.cmd_exitcode(cmd, timeout)
84         logger.debug(
85             f'____ (↑↑↑ subprocess finished in {t():.2f}s, exit value was {ret} ↑↑↑) ____'
86         )
87         if timestamp_file is not None and os.path.exists(timestamp_file):
88             logger.debug('Touching %s', timestamp_file)
89             file_utils.touch_file(timestamp_file)
90         return ret
91     except Exception as e:
92         logger.exception(e)
93         print('Cron subprocess failed, giving up.', file=sys.stderr)
94         logger.warning('Cron subprocess failed, giving up')
95         return -1000
96
97
98 @bootstrap.initialize
99 def main() -> int:
100     """Entry point"""
101     if config.config['timestamp']:
102         timestamp_file = f"/timestamps/{config.config['timestamp']}"
103         if not file_utils.does_file_exist(timestamp_file):
104             logger.error(
105                 '--timestamp argument\'s target file (%s) must already exist.',
106                 timestamp_file,
107             )
108             sys.exit(-1)
109     else:
110         timestamp_file = None
111         if config.config['max_frequency']:
112             config.error(
113                 'The --max_frequency argument requires the --timestamp argument.'
114             )
115
116     now = datetime.datetime.now()
117     if timestamp_file is not None and os.path.exists(timestamp_file):
118         max_frequency = config.config['max_frequency']
119         if max_frequency is not None:
120             max_delta = datetime_utils.parse_duration(max_frequency)
121             if max_delta > 0:
122                 mtime = file_utils.get_file_mtime_as_datetime(timestamp_file)
123                 delta = now - mtime
124                 if delta.total_seconds() < max_delta:
125                     logger.info(
126                         "It's only been %s since we last ran successfully; bailing out.",
127                         datetime_utils.describe_duration_briefly(delta.total_seconds()),
128                     )
129                     sys.exit(0)
130
131     timeout = config.config['timeout']
132     if timeout is not None:
133         timeout = datetime_utils.parse_duration(timeout)
134         assert timeout > 0
135         logger.debug('Timeout is %ss', timeout)
136         lockfile_expiration = datetime.datetime.now().timestamp() + timeout
137     else:
138         logger.debug('Timeout not specified; no lockfile expiration.')
139         lockfile_expiration = None
140
141     lockfile_path = config.config['lockfile']
142     if lockfile_path is not None:
143         logger.debug('Attempting to acquire lockfile %s...', lockfile_path)
144         try:
145             with lockfile.LockFile(
146                 lockfile_path,
147                 do_signal_cleanup=True,
148                 override_command=' '.join(config.config['command']),
149                 expiration_timestamp=lockfile_expiration,
150             ):
151                 return run_command(timeout, timestamp_file)
152         except lockfile.LockFileException as e:
153             logger.exception(e)
154             msg = f'Failed to acquire {lockfile_path}, giving up.'
155             logger.error(msg)
156             print(msg, file=sys.stderr)
157             return 1000
158     else:
159         logger.debug('No lockfile indicated; not locking anything.')
160         return run_command(timeout, timestamp_file)
161
162
163 if __name__ == '__main__':
164     # Insist that our logger.whatever('messages') make their way into
165     # syslog with a facility=LOG_CRON, please.  Yes, this is hacky.
166     sys.argv.append('--logging_syslog')
167     sys.argv.append('--logging_syslog_facility=CRON')
168     main()