96 lines
4.1 KiB
Python
96 lines
4.1 KiB
Python
"""Read scheduler metadata only; never run jobs or report their arguments."""
|
|
from pathlib import Path
|
|
import re
|
|
import shlex
|
|
|
|
SAFE_PATH = re.compile(r'^[A-Za-z0-9_./+-]+$')
|
|
|
|
|
|
def cron_rows(text, source, system_file=False):
|
|
rows = []
|
|
for number, raw in enumerate(text.splitlines(), 1):
|
|
line = raw.strip()
|
|
if not line or line.startswith('#') or re.match(r'^[A-Za-z_][A-Za-z0-9_]*\s*=', line):
|
|
continue
|
|
parts = line.split(None, 1 if line.startswith('@') else 5)
|
|
count = 1 if line.startswith('@') else 5
|
|
if count == 1 and parts[0] not in {'@reboot', '@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight', '@hourly'}:
|
|
continue
|
|
if len(parts) <= count:
|
|
continue
|
|
schedule, command = ' '.join(parts[:count]), parts[count]
|
|
account = 'Current user'
|
|
if system_file:
|
|
user_command = command.split(None, 1)
|
|
if len(user_command) != 2:
|
|
continue
|
|
account, command = user_command
|
|
# A safe executable/script filename is enough to identify the workload.
|
|
# Arguments, embedded scripts and environment assignments never leave here.
|
|
try:
|
|
tokens = shlex.split(command)
|
|
except ValueError:
|
|
tokens = []
|
|
executable = tokens[0] if tokens and SAFE_PATH.fullmatch(tokens[0]) else '[command omitted]'
|
|
script = executable
|
|
if Path(executable).name.startswith(('python', 'perl', 'ruby', 'bash', 'sh')) and len(tokens) > 1:
|
|
if SAFE_PATH.fullmatch(tokens[1]) and not tokens[1].startswith('-'):
|
|
script = tokens[1]
|
|
rows.append({'name': f'{Path(script).name} ({source}:{number})', 'path': source,
|
|
'enabled': True, 'account': account, 'executable': executable,
|
|
'triggers': schedule + ' (cron; timezone not resolved)',
|
|
'state': 'Configured schedule; execution unverified'})
|
|
return rows
|
|
|
|
|
|
def read_system_cron(cancelled=lambda: False):
|
|
paths = [Path('/etc/crontab')]
|
|
directory = Path('/etc/cron.d')
|
|
if directory.exists():
|
|
paths.extend(sorted(directory.iterdir()))
|
|
rows, incomplete = [], False
|
|
incomplete = len(paths) > 256
|
|
for path in paths[:256]:
|
|
if cancelled():
|
|
from samsara.collectors.linux import CollectionCancelled
|
|
raise CollectionCancelled()
|
|
if not path.exists():
|
|
continue
|
|
# Avoid following symlinks or special files. Bound every file read.
|
|
if path.is_symlink() or not path.is_file():
|
|
incomplete = True
|
|
continue
|
|
if path.name.startswith('.') or path.name.endswith(('~', '.bak', '.dpkg-old', '.rpmnew', '.rpmsave')):
|
|
continue
|
|
try:
|
|
with path.open('r', encoding='utf-8', errors='replace') as source:
|
|
text = source.read(65537)
|
|
if len(text) > 65536:
|
|
incomplete = True
|
|
continue
|
|
rows.extend(cron_rows(text, str(path), system_file=True))
|
|
except OSError:
|
|
incomplete = True
|
|
return rows, incomplete
|
|
|
|
|
|
def enrich_timers(rows, command):
|
|
names = [row['name'] for row in rows if re.fullmatch(r'[A-Za-z0-9_.@:-]+\.timer', row['name'])]
|
|
if not names:
|
|
return
|
|
output = command(['systemctl', 'show', '--property=Id', '--property=TimersCalendar',
|
|
'--property=NextElapseUSecRealtime', '--property=Triggers', '--no-pager', '--', *names[:200]])
|
|
details = {}
|
|
for block in output.strip().split('\n\n'):
|
|
properties = dict(line.split('=', 1) for line in block.splitlines() if '=' in line)
|
|
if 'Id' in properties:
|
|
details[properties['Id']] = properties
|
|
for row in rows:
|
|
properties = details.get(row['name'], {})
|
|
schedule = properties.get('TimersCalendar', '')
|
|
next_run = properties.get('NextElapseUSecRealtime', '')
|
|
if schedule or next_run:
|
|
row['triggers'] = '; '.join(filter(None, [schedule, 'Next: ' + next_run if next_run else '']))
|
|
if properties.get('Triggers'):
|
|
row['associated_unit'] = properties['Triggers']
|