...
 
Commits (13)
......@@ -7,6 +7,7 @@ Benefits of `hat`:
- Job modification is supported; you can easily modify command, time of an enqueued job
- Flexible datetime specifications, see https://github.com/heemayl/humantime-epoch-converter
- Will run a scheduled job later when the computer was off at that time, so no job will be missed
- Option for running a job at the specified time only e.g. if the computer was off at that time, job will not be run
- User specific jobs, secured approach
- User based logging, all logs from jobs of a user go in `~/.hatd/logs/`
- All-in-one i.e. no separate tool based on job or pattern
......@@ -107,6 +108,43 @@ ID Time Shell Command
1 2018-02-09T14:30:42 - free -m
```
Now, option for running a job at only at the specified time is available. By default, a job is will be run later if e.g. the computer was off at the desired run time; `-e`/`--exact` option disables this behavior:
```bash
% hatc -l
Job queue is empty
# Adding job with `--exact`
% hatc --exact --add 'free -g' 'now+1h3m40s'
{'msg': 'Done'}
# Check out the third column
% hatc -l
ID Time Exact Shell Command
1 2018-02-12T03:13:44 Yes - free -g
# Let's disable exact by modifying the job without `-e`/`--exact`
% hatc -m 1 _ _
{'msg': 'Done'}
# exact is disabled
% hatc -l
ID Time Exact Shell Command
1 2018-02-12T03:13:44 No - free -g
# Enable exact again
% hatc -e -m 1 _ _
{'msg': 'Done'}
# Enabled again
% hatc -l
ID Time Exact Shell Command
1 2018-02-12T03:13:44 Yes - free -g
```
---
#!/bin/sh
# The client for the hat-daemon. This is just
# a wrapper around client.py, passes the
# argumemts as-is to client.py
# a wrapper around client.py that passes the
# arguments as-is to client.py.
# sourcing env_base
. /usr/lib/hatd/env_base.sh
......
......@@ -64,44 +64,53 @@ def parse_arguments():
result = ' '.join(formats) % get_metavar(action.nargs)
return result
parser = argparse.ArgumentParser(prog='hatc', description='hat client',
parser = argparse.ArgumentParser(prog='hatc', description='HAT client – a client for HAT (Hyper-AT), the one-time scheduler for GNU/Linux.',
formatter_class=ManualFormatter)
parser.add_argument('-l', '--list', dest='joblist',
required=False, action='store_true',
help='Show the list of queued jobs for current user\n')
help='Show the list of queued jobs.\n')
parser.add_argument('-c', '--count', dest='jobcount',
required=False, action='store_true',
help='Show the number of queued jobs for current user\n\n')
help='Show the number of queued jobs.\n\n')
parser.add_argument('-e', '--exact', dest='exact',
required=False, action='store_true',
help='Run the job only at the time specified, not after. By default, a job is will be run later if e.g. the computer was off at the desired run time.\n\n')
parser.add_argument('-a', '--add', dest='add_job',
metavar='<command> <datetime_spec> [<shell>]', nargs='+',
required=False, help="""Add a new job. If shell is specified, the job will be run in the given shell, otherwise no shell will be used. Example:
required=False, help="""Add a new job. If shell is specified, the job will be run in the given shell,
otherwise no shell will be used.
Examples:
hatc --add 'free -m' 'now + 30 min'
hatc --add 'free -g' 'now+1h40m30s'
hatc -a 'tail -10 /var/log/syslog' 'tomorrow at 14:30'
hatc -a 'func() { echo Test ;}; func()' 'next sunday 11' bash
hatc -a 'echo $PATH' 'today 18:06:34' dash
hatc -a date 'tomorrow 10 - 6 hr 12 min 3 sec'
hatc -e -a 'free -g' 'now+1h' sh # Making the job exact, see `-e`/`--exact`
More on <datetime_spec>: https://github.com/heemayl/humantime-epoch-converter
Job's STDOUT, STDERR will be logged in `~/.hatd/logs/{stdout,stderr}.log`, respectively.
More on <datetime_spec>: https://github.com/heemayl/humantime-epoch-converter
The job's STDOUT and STDERR are logged in `~/.hatd/logs/{stdout,stderr}.log`, respectively.
"""
)
parser.add_argument('-m', '--modify', dest='modify_job',
metavar='<job_id> <command> <datetime_spec> [<shell>]', nargs='+',
required=False, help="""Modify an enqueued job. The first argument must be the job ID (from `hatc -l`). `_` can be used as a placeholder for using an
already saved value for an argument (except <job_id>). If <shell> is used, <command> must be specified explicitly. Example:
required=False, help="""Modify an enqueued job. The first argument must be the job ID (from `hatc -l`).
`_` can be used as a placeholder for using an already saved value for an argument (except <job_id>).
If <shell> is used, <command> must be specified explicitly, and vice versa. The `-e`/`--exact` argument must be specified explicitly too.
Examples:
hatc --modify 2 'free -g' 'now + 30 min' # Everything is updated for Job with ID 2
hatc -m 31 _ 'tomorrow at 14:30' # The command is kept as original, only time is updated
hatc -m 4 'func() { echo Test ;}; func()' _ # Only command is updated
hatc -m 23 'echo $PATH' 'today 18:06:34' dash # Everything is updated
hatc --modify 78 _ 'tomorrow 10 - 6 hr 12 min 3 sec' # Only time is updated
hatc --exact -m 2 _ _ # Making the job to run at exact time, not anytime after; keeping command/time specifications as-is
hatc -m 2 _ _ # Removing the exact option from the previous job, by not using `-e` or `--exact` option
"""
)
parser.add_argument('-r', '--remove', dest='remove_job',
metavar='<job_id> [<job_id> ...]', nargs='+',
required=False, help="""Remove queued job(s) by Job ID. Example:
required=False, help="""Remove queued job(s) by Job ID.
Examples:
hatc --remove 12
hatc -r 3 8 23
"""
......@@ -115,17 +124,18 @@ already saved value for an argument (except <job_id>). If <shell> is used, <comm
def argument_serializer(args_dict):
'''Checks the argument dict, and returns the args
as a sequence after passing them through decision logic.
'''
'''
exact = args_dict.get('exact', False)
if not any(args_dict.values()) or args_dict.get('joblist'):
return ('joblist',)
elif args_dict.get('jobcount'):
return ('jobcount',)
elif args_dict.get('add_job'):
return ('add_job', *args_dict.get('add_job'))
return ('add_job', *([exact] + args_dict.get('add_job')))
elif args_dict.get('modify_job'):
return ('modify_job', *args_dict.get('modify_job'))
return ('modify_job', *([exact] + args_dict.get('modify_job')))
elif args_dict.get('remove_job'):
return ('remove_job', *args_dict.get('remove_job'))
return ('remove_job', *([exact] + args_dict.get('remove_job')))
return
......@@ -156,7 +166,7 @@ def json_to_table_print(json_str):
data = json.loads(r'{}'.format(json_str))
if data:
# Header
to_print = '\t\t'.join(('ID', 'Time', 'Shell', 'Command'))
to_print = '\t\t'.join(('ID', 'Time', 'Exact', 'Shell', 'Command'))
# Sorting list according to the Epoch, then ID
data.sort(key=lambda x: (x[1]['job_run_at'], x[0]))
for job in data:
......@@ -165,9 +175,12 @@ def json_to_table_print(json_str):
shell = job[1]['use_shell'] or ' -'
time_ = time.strftime('%Y-%m-%dT%H:%M:%S',
time.localtime(job[1]['job_run_at']))
exact = ' Yes' if job[1]['exact'] else ' No'
to_print = '{}\n{}'.format(to_print,
'\t'.join((job_id, time_, shell,
'\t{}'.format(command))))
'\t'.join((job_id, time_, exact,
'\t{}'.format(shell),
'\t{}'.format(command)
)))
return to_print
return 'Job queue is empty'
......@@ -202,40 +215,43 @@ class SendReceiveData:
self.send_to_daemon()
def add_job_fmt(self, data):
if not (2 <= len(data) <= 3):
if not (3 <= len(data) <= 4):
raise HatClientException('Ambiguous input')
command = '{} -c "{}"'.format(data[2], data[0]) if len(data) == 3 \
else data[0]
exact = data[0]
command = '{} -c "{}"'.format(data[3], data[1]) if len(data) == 4 \
else data[1]
time_ = time.strftime('%Y-%m-%d_%H:%M:%S',
time.localtime(get_epoch_main(data[1])))
time.localtime(get_epoch_main(data[2])))
self.out_dict = {
'add_job': {
'euid': os.geteuid(),
'exact': exact,
'command': command,
'time_': time_,
'use_shell': data[2] if len(data) == 3 else False
'use_shell': data[3] if len(data) == 4 else False
}
}
def modify_job_fmt(self, data):
if not (3 <= len(data) <= 4):
if not (4 <= len(data) <= 5):
raise HatClientException('Ambiguous input')
try:
job_id = int(data[0])
job_id = int(data[1])
except ValueError:
raise HatClientException('Ambiguous input')
command = '{} -c "{}"'.format(data[3], data[1]) if len(data) == 4 \
else data[1]
time_ = data[2] if data[2] == '_' else time.strftime(
exact = data[0]
command = '{} -c "{}"'.format(data[4], data[2]) if len(data) == 5 \
else data[2]
time_ = data[3] if data[3] == '_' else time.strftime(
'%Y-%m-%d_%H:%M:%S',
time.localtime(get_epoch_main(data[2])))
time.localtime(get_epoch_main(data[3])))
self.out_dict = {
'add_job': {
'euid': os.geteuid(),
'exact': exact,
'command': command,
'time_': time_,
'use_shell': data[3] if len(data) == 4 else False,
'use_shell': data[4] if len(data) == 5 else False,
'job_id': job_id
}
}
......
......@@ -69,11 +69,13 @@ class HatDaemon(metaclass=HatDaemonMeta):
def pid(self):
return self.daemon.pid
def add_job(self, euid, command, time_, use_shell=False, job_id=None):
def add_job(self, euid, exact, command, time_, use_shell=False,
job_id=None):
'''Adds a new job.'''
# Sending `job` dict to fifo with required params
job = {
'euid': euid,
'exact': exact,
'command': command,
'time_': time_,
'use_shell': use_shell,
......
......@@ -23,11 +23,11 @@ class DateTime:
to Python datetime object and converts that into Epoch eventually.
'''
def __init__(self, str_dt):
self.str_dt = re.sub(r'([+-]\s+\d+)([a-z]+)',
r'\1 \2', str_dt.lower().strip().strip(':')
.replace('+', ' + ')
.replace('-', ' - ')
)
self.str_dt = ' '.join(re.split(r'(\d+)([a-z]+)',
str_dt.lower().strip().strip(':')
.replace('+', ' + ')
.replace('-', ' - ')
))
self.list_dt = self.str_dt.split()
def check_get(self):
......@@ -182,11 +182,11 @@ class DateTime:
'''Adds hr/min/sec to time.'''
hrs = mins = secs = 0
for val, name in itertools.zip_longest(after_[::2], after_[1::2]):
if name in {'hrs', 'hr', 'hours', 'hour'}:
if name in {'hours', 'hour', 'hrs', 'hr', 'h'}:
hrs = int(val)
elif name in {'minutes', 'minute', 'mins', 'min'}:
elif name in {'minutes', 'minute', 'mins', 'min', 'm'}:
mins = int(val)
elif name in {'seconds', 'second', 'secs', 'sec', None}:
elif name in {'seconds', 'second', 'secs', 'sec', 's', None}:
secs = int(val)
else:
raise DateTimeException('Ambiguous input: {}'
......
......@@ -28,7 +28,7 @@ class BaseRunnerMeta(type):
class BaseRunner(metaclass=BaseRunnerMeta):
'''The base command runner. This is a singleton.'''
'''The base command runner. This is a singleton.'''
def __init__(self):
self.daemon_log = '/var/log/hatd/daemon.log'
self.fifo_in = '/var/run/hatd/ipc/runner_in.fifo'
......@@ -69,17 +69,18 @@ class BaseRunner(metaclass=BaseRunnerMeta):
while True:
if not self._running:
break
to_remove = []
to_remove = set()
for line in fifo_in:
try:
content = json.loads(line.strip())
except json.JSONDecodeError as e:
write_file(self.daemon_log, str(e), mode='at')
else:
if 3 <= len(content) <= 5:
if 4 <= len(content) <= 6:
try:
Job(
int(content['euid']),
content['exact'],
content['command'],
content['time_'],
content.get('use_shell', False),
......@@ -121,12 +122,18 @@ class BaseRunner(metaclass=BaseRunnerMeta):
# {'remove': [(euid, job_id), ...]}
elif 'remove' in content:
for euid, job_id in content['remove']:
to_remove.append((euid, int(job_id)))
to_remove.add((euid, int(job_id)))
_jobs = self._joblist_raw(-1)
if _jobs:
for euid, job_dict in _jobs.items():
for job_id, job in job_dict.items():
if job['job_run_at'] <= int(time.time()):
job_run_at = job['job_run_at']
current_time = int(time.time())
# Considering 2 secs margin for load etc.
if (current_time - 2 <= job_run_at <=
current_time) or (
job_run_at < current_time
and not job['exact']):
multiprocessing.Process(
target=self.command_run_save,
args=(job['command'],),
......@@ -144,7 +151,9 @@ class BaseRunner(metaclass=BaseRunnerMeta):
'run_at': job['job_run_at'],
},
).start()
to_remove.append((euid, job_id))
to_remove.add((euid, job_id))
if (job_run_at < current_time) and job['exact']:
to_remove.add((euid, job_id))
if to_remove:
for euid, job_id in to_remove:
remove_job(euid, job_id)
......
......@@ -6,6 +6,7 @@ import collections
import datetime
import os
import pickle
import re
import time
from abc import ABCMeta
......@@ -98,7 +99,8 @@ class JobMeta(ABCMeta):
class Job(metaclass=JobMeta):
'''A job to be done at specified time.'''
def __init__(self, euid, command, time_, use_shell=False, job_id=None):
def __init__(self, euid, exact, command, time_, use_shell=False,
job_id=None):
self.euid = int(euid)
# Checking Permission
# _check_perm(self.euid)
......@@ -106,6 +108,8 @@ class Job(metaclass=JobMeta):
if job_id:
job = enqueued_jobs[self.euid][job_id]
self.job_id = job_id
# Hmmm...future thinking scope
self.exact = exact
self.command = job['command'] if command == '_' \
else command
if time_ == '_':
......@@ -115,15 +119,21 @@ class Job(metaclass=JobMeta):
self.date_time_epoch = self.get_run_at_epoch()
self.use_shell = job['use_shell'] if use_shell == '_' \
else use_shell
# use_shell is absent: removing shell reference from command
if not self.use_shell and not use_shell == '_':
self.command = re.sub(r'^\S+\s+-c\s+"(.*)"$', r'\1',
self.command)
else:
self.command = command
self.use_shell = use_shell
self.exact = exact
self.time_str = time_
self.date_time_epoch = self.get_run_at_epoch()
# Saving the job, with the user's EUID as keys, and increasing
# IDs as subdict keys with command, time, use_shell as values
self.job_id = self._get_job_id()
if not self.date_time_epoch:
return
enqueued_jobs[self.euid].update({
......@@ -131,6 +141,7 @@ class Job(metaclass=JobMeta):
'command': self.command,
'job_run_at': int(self.date_time_epoch), # to int
'use_shell': self.use_shell,
'exact': self.exact,
}
})
......@@ -170,7 +181,9 @@ class Job(metaclass=JobMeta):
continue
else:
self.date_time_epoch = time.mktime(self.date_time.timetuple())
if self.date_time_epoch < time.time():
# Taking a margin of 2 secs so that time
# spec like `now` works
if self.date_time_epoch < time.time() - 2:
raise HatTimerException(
'No backward time travel support: {}'
.format(self.time_str))
......