387 lines
20 KiB
Python
387 lines
20 KiB
Python
"""Root-owned task store and native systemd schedules; no third-party modules."""
|
||
from contextlib import contextmanager
|
||
import datetime as dt
|
||
import hashlib
|
||
import json
|
||
import os
|
||
from pathlib import Path, PurePosixPath
|
||
import re
|
||
import shlex
|
||
import sqlite3
|
||
import subprocess
|
||
import time
|
||
import uuid
|
||
|
||
BASE=Path(os.environ.get('HARDWARE_TASK_DATA','/var/lib/hardware-taskd'))
|
||
LOGS=Path(os.environ.get('HARDWARE_TASK_LOGS','/var/log/hardware-taskd'))
|
||
UNITS=Path(os.environ.get('HARDWARE_TASK_UNITS','/etc/systemd/system'))
|
||
APP=Path(__file__).resolve().parent
|
||
TZ=dt.timezone(dt.timedelta(hours=8))
|
||
WEEKDAYS=['Mon','Tue','Wed','Thu','Fri','Sat','Sun']
|
||
|
||
class TaskError(Exception):
|
||
def __init__(self,message,status=400):
|
||
self.status=status
|
||
super().__init__(message)
|
||
|
||
def iso(value=None):
|
||
return dt.datetime.fromtimestamp(time.time() if value is None else value,TZ).isoformat(timespec='seconds')
|
||
|
||
@contextmanager
|
||
def db():
|
||
con=sqlite3.connect(BASE/'tasks.sqlite3',timeout=10)
|
||
con.row_factory=sqlite3.Row
|
||
try:
|
||
yield con;con.commit()
|
||
except Exception:
|
||
con.rollback();raise
|
||
finally:
|
||
con.close()
|
||
|
||
def init_store():
|
||
BASE.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||
LOGS.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||
(BASE/'scripts').mkdir(exist_ok=True,mode=0o700)
|
||
with db() as con:
|
||
con.execute('PRAGMA journal_mode=WAL')
|
||
con.executescript('''
|
||
CREATE TABLE IF NOT EXISTS tasks(id TEXT PRIMARY KEY,name TEXT,definition TEXT,
|
||
enabled INTEGER,deleted INTEGER DEFAULT 0,created REAL,updated REAL,revision INTEGER);
|
||
CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY,task_id TEXT,task_name TEXT,
|
||
started REAL,finished REAL,state TEXT,exit_code INTEGER,trigger TEXT,boot TEXT,
|
||
script_hash TEXT,log_bytes INTEGER DEFAULT 0,truncated INTEGER DEFAULT 0,revision INTEGER,duration REAL,
|
||
log_purged INTEGER DEFAULT 0);
|
||
CREATE INDEX IF NOT EXISTS run_task ON runs(task_id,started DESC);
|
||
CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY AUTOINCREMENT,at REAL,
|
||
action TEXT,task_id TEXT,actor TEXT,client TEXT,detail TEXT);
|
||
CREATE TABLE IF NOT EXISTS requests(task_id TEXT PRIMARY KEY,trigger TEXT,expires REAL);
|
||
''')
|
||
|
||
def atom_write(path,text,mode=0o600):
|
||
temporary=Path(str(path)+'.tmp')
|
||
temporary.write_text(text,encoding='utf-8');os.chmod(temporary,mode);os.replace(temporary,path)
|
||
|
||
def validate_id(value):
|
||
if not isinstance(value,str) or not re.fullmatch('[0-9a-f]{32}',value):
|
||
raise TaskError('任务编号不正确。')
|
||
return value
|
||
|
||
def execute(args,timeout=15,check=True):
|
||
try:
|
||
p=subprocess.run(args,capture_output=True,text=True,timeout=timeout,env={**os.environ,'LC_ALL':'C','TZ':'Asia/Shanghai'})
|
||
except (OSError,subprocess.TimeoutExpired) as exc:
|
||
raise TaskError('系统调度操作未完成:'+str(exc),503)
|
||
if check and p.returncode:
|
||
raise TaskError('系统调度操作失败:'+(p.stderr or p.stdout)[-1200:],503)
|
||
return p
|
||
|
||
def number(value,minimum,maximum,label):
|
||
try:
|
||
if isinstance(value,bool):raise ValueError()
|
||
n=int(str(value))
|
||
except (ValueError,TypeError):
|
||
raise TaskError(label+'必须是整数。')
|
||
if not minimum<=n<=maximum:
|
||
raise TaskError(f'{label}必须在 {minimum} 至 {maximum} 之间。')
|
||
return n
|
||
|
||
def clock(value):
|
||
if not isinstance(value,str) or not re.fullmatch(r'\d{2}:\d{2}',value):
|
||
raise TaskError('执行时间格式应为 03:00。')
|
||
h,m=map(int,value.split(':'))
|
||
if h>23 or m>59:raise TaskError('执行时间不正确。')
|
||
return value+':00'
|
||
|
||
def cron_field(value,low,high,names=None):
|
||
value=value.upper()
|
||
if names:
|
||
value=re.sub('[A-Z]{3}',lambda m:str(names[m[0]]) if m[0] in names else m[0],value)
|
||
result=set()
|
||
for part in value.split(','):
|
||
pieces=part.split('/')
|
||
if len(pieces)>2:raise TaskError('Cron 的步长格式不正确。')
|
||
step=number(pieces[1],1,high-low+1,'Cron 步长') if len(pieces)==2 else 1
|
||
base=pieces[0]
|
||
if base=='*':start,end=low,high
|
||
elif re.fullmatch(r'\d+-\d+',base):start,end=map(int,base.split('-'))
|
||
elif base.isdigit():start=int(base);end=high if len(pieces)==2 else start
|
||
else:raise TaskError('Cron 仅支持数字、*、范围、逗号和步长;不支持 L、W、# 或 ?。')
|
||
if not low<=start<=end<=high:raise TaskError('Cron 字段数值超出允许范围。')
|
||
result.update(range(start,end+1,step))
|
||
return sorted(result)
|
||
|
||
def cron_calendars(expression):
|
||
parts=expression.split()
|
||
if len(parts)!=5:raise TaskError('Cron 需要五段:分 时 日 月 周,例如 0 3 * * *。')
|
||
minute=cron_field(parts[0],0,59);hour=cron_field(parts[1],0,23)
|
||
day=cron_field(parts[2],1,31)
|
||
month=cron_field(parts[3],1,12,{x:i+1 for i,x in enumerate(['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'])})
|
||
dow=cron_field(parts[4],0,7,{'SUN':0,'MON':1,'TUE':2,'WED':3,'THU':4,'FRI':5,'SAT':6})
|
||
join=lambda values:','.join(map(str,values))
|
||
months='*' if parts[3]=='*' else join(month)
|
||
hours='*' if parts[1]=='*' else join(hour)
|
||
minutes='*' if parts[0]=='*' else join(minute)
|
||
dates='*' if parts[2]=='*' else join(day)
|
||
days=','.join(['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][v] for v in sorted({v%7 for v in dow}))
|
||
timepart=f'{hours}:{minutes}:00 Asia/Shanghai'
|
||
if parts[4]=='*':return [f'*-{months}-{dates} {timepart}']
|
||
if parts[2]=='*':return [f'{days} *-{months}-* {timepart}']
|
||
# Traditional cron: restricted day-of-month and weekday are joined with OR.
|
||
return [f'*-{months}-{dates} {timepart}',f'{days} *-{months}-* {timepart}']
|
||
|
||
def schedule_definition(schedule):
|
||
if not isinstance(schedule,dict):raise TaskError('请设置执行周期。')
|
||
kind=schedule.get('kind','daily')
|
||
if kind=='daily':
|
||
value=clock(schedule.get('time','03:00'))
|
||
return [f'OnCalendar=*-*-* {value} Asia/Shanghai'],f'每天 {value[:5]}'
|
||
if kind=='weekly':
|
||
chosen=schedule.get('weekdays',[0])
|
||
if not isinstance(chosen,list) or not chosen:raise TaskError('至少选择一个星期。')
|
||
selected=sorted({number(v,0,6,'星期') for v in chosen})
|
||
value=clock(schedule.get('time','03:00'))
|
||
days=','.join(WEEKDAYS[v] for v in selected)
|
||
label='、'.join('周'+['一','二','三','四','五','六','日'][v] for v in selected)
|
||
return [f'OnCalendar={days} *-*-* {value} Asia/Shanghai'],f'每{label} {value[:5]}'
|
||
if kind=='monthly':
|
||
day=number(schedule.get('day',1),1,31,'每月日期');value=clock(schedule.get('time','03:00'))
|
||
return [f'OnCalendar=*-*-{day} {value} Asia/Shanghai'],f'每月 {day} 日 {value[:5]}'+('(当月没有该日则跳过)' if day>28 else '')
|
||
if kind in ('minutes','hours'):
|
||
every=number(schedule.get('every',1),1,1440 if kind=='minutes' else 168,'间隔')
|
||
seconds=every*(60 if kind=='minutes' else 3600)
|
||
return [f'OnActiveSec={seconds}s',f'OnUnitInactiveSec={seconds}s'],f'每 {every} '+('分钟' if kind=='minutes' else '小时')+'(上次执行结束后重新计时)'
|
||
if kind=='once':
|
||
try:
|
||
at=dt.datetime.fromisoformat(schedule.get('at',''))
|
||
if at.tzinfo is None:at=at.replace(tzinfo=TZ)
|
||
at=at.replace(second=0,microsecond=0)
|
||
except (ValueError,TypeError):raise TaskError('请填写正确的指定日期与时间。')
|
||
if at.timestamp()<time.time()+30:raise TaskError('指定执行时间必须至少晚于现在 30 秒。')
|
||
return [f'OnCalendar={at.astimezone(TZ).strftime("%Y-%m-%d %H:%M:00")} Asia/Shanghai'],f'仅执行一次:{at.astimezone(TZ).strftime("%Y-%m-%d %H:%M")}'
|
||
if kind=='cron':
|
||
expression=str(schedule.get('expression','')).strip()
|
||
if len(expression)>150:raise TaskError('Cron 表达式过长。')
|
||
return ['OnCalendar='+x for x in cron_calendars(expression)],'Cron:'+expression
|
||
raise TaskError('不支持的执行周期。')
|
||
|
||
def validate_definition(payload,check_paths=True):
|
||
name=str(payload.get('name','')).strip()
|
||
if not 1<=len(name)<=80 or any(ord(c)<32 for c in name):raise TaskError('任务名称需要 1 至 80 个字符,不能包含换行。')
|
||
mode=payload.get('mode','command');interpreter=payload.get('interpreter','bash')
|
||
if mode not in ('command','script','file'):raise TaskError('请选择命令、脚本内容或服务器脚本文件。')
|
||
if interpreter not in ('bash','python3','auto'):raise TaskError('脚本语言不正确。')
|
||
body=str(payload.get('body','')).replace('\r\n','\n').replace('\r','\n').lstrip('\ufeff').strip()
|
||
path=str(payload.get('path','')).strip()
|
||
arguments=str(payload.get('arguments','')).strip()
|
||
if mode in ('command','script') and not body:raise TaskError('请输入要执行的命令或脚本。')
|
||
if len(body.encode())>65536:raise TaskError('脚本内容不能超过 64 KiB。')
|
||
if mode=='script' and interpreter=='auto':raise TaskError('脚本内容请选择 Bash 或 Python 3。')
|
||
cwd=str(payload.get('cwd','/root')).strip() or '/root'
|
||
if not PurePosixPath(cwd).is_absolute() or '\x00' in cwd:raise TaskError('工作目录必须是绝对路径。')
|
||
if check_paths and not Path(cwd).is_dir():raise TaskError('工作目录不存在。')
|
||
if mode=='file':
|
||
if not PurePosixPath(path).is_absolute() or '\x00' in path:raise TaskError('脚本文件需要填写服务器上的绝对路径。')
|
||
if check_paths and not Path(path).is_file():raise TaskError('服务器上找不到这个脚本文件。')
|
||
if check_paths and interpreter=='auto' and not os.access(path,os.X_OK):raise TaskError('该文件没有执行权限,请选择 Bash/Python 3 解释器,或先设置文件执行权限。')
|
||
try:shlex.split(arguments)
|
||
except ValueError:raise TaskError('脚本参数的引号不匹配。')
|
||
lines,label=schedule_definition(payload.get('schedule',{}))
|
||
notes=str(payload.get('notes','')).strip()[:400]
|
||
timeout=number(payload.get('timeout',1800),10,86400,'最长执行秒数')
|
||
if not isinstance(payload.get('enabled',False),bool):raise TaskError('启用状态必须为是或否。')
|
||
dangerous=bool(re.search(r'\b(reboot|shutdown|poweroff|halt)\b|\binit\s+[06]\b',body+' '+path,re.I))
|
||
return {'name':name,'mode':mode,'interpreter':interpreter,'body':body,'path':path,'arguments':arguments,
|
||
'cwd':cwd,'schedule':payload.get('schedule',{}),'schedule_lines':lines,'schedule_label':label,
|
||
'timeout':timeout,'notes':notes,'enabled':bool(payload.get('enabled',False)),
|
||
'dangerous':dangerous,'template':str(payload.get('template','custom'))[:40]}
|
||
|
||
def calendar_preview(definition):
|
||
result=[]
|
||
for line in definition['schedule_lines']:
|
||
if line.startswith('OnCalendar='):
|
||
p=execute(['systemd-analyze','calendar','--iterations=1',line.split('=',1)[1]],timeout=5,check=False)
|
||
if p.returncode:raise TaskError('执行周期无效:'+(p.stderr or p.stdout)[-600:])
|
||
result.append(p.stdout.strip())
|
||
if result and all(re.search(r'Next elapse:\s*n/a',output) for output in result):
|
||
raise TaskError('这个周期没有可以执行的将来时间,请检查日期或 Cron 表达式。')
|
||
return result
|
||
|
||
def get_task(task_id,include_deleted=False):
|
||
validate_id(task_id)
|
||
with db() as con:
|
||
row=con.execute('SELECT * FROM tasks WHERE id=?'+('' if include_deleted else ' AND deleted=0'),(task_id,)).fetchone()
|
||
if not row:raise TaskError('找不到这个任务。',404)
|
||
item=dict(row);item['definition']=json.loads(item['definition']);return item
|
||
|
||
def unit_name(task_id):return 'hwjob-'+validate_id(task_id)
|
||
|
||
def running(task_id):
|
||
p=execute(['systemctl','is-active',unit_name(task_id)+'.service'],check=False,timeout=5)
|
||
return p.stdout.strip() in ('active','activating','deactivating')
|
||
|
||
def render_units(task_id,definition):
|
||
name=unit_name(task_id)
|
||
service=f'''[Unit]
|
||
Description=Dashboard scheduled task {task_id}
|
||
After=network-online.target
|
||
Wants=network-online.target
|
||
|
||
[Service]
|
||
Type=simple
|
||
User=root
|
||
ExecStart=/usr/bin/python3 {APP}/task_exec.py {task_id}
|
||
RuntimeMaxSec={definition['timeout']+10}
|
||
TimeoutStopSec=15
|
||
KillMode=mixed
|
||
Restart=no
|
||
UMask=0077
|
||
Environment=TZ=Asia/Shanghai
|
||
'''
|
||
timer=f'''[Unit]
|
||
Description=Dashboard task schedule {task_id}
|
||
|
||
[Timer]
|
||
{chr(10).join(definition['schedule_lines'])}
|
||
AccuracySec=1s
|
||
Persistent=false
|
||
Unit={name}.service
|
||
|
||
[Install]
|
||
WantedBy=timers.target
|
||
'''
|
||
return service,timer
|
||
|
||
def install_units(task_id,definition):
|
||
service,timer=render_units(task_id,definition)
|
||
name=unit_name(task_id)
|
||
atom_write(UNITS/(name+'.service'),service,0o644)
|
||
atom_write(UNITS/(name+'.timer'),timer,0o644)
|
||
execute(['systemctl','daemon-reload'])
|
||
|
||
def audit(action,task_id='',actor='admin',client='',detail=''):
|
||
with db() as con:
|
||
con.execute('INSERT INTO audit(at,action,task_id,actor,client,detail) VALUES(?,?,?,?,?,?)',
|
||
(time.time(),action,task_id,actor,client,detail[:2000]))
|
||
|
||
def save_task(payload,client=''):
|
||
task_id=payload.get('id')
|
||
old=get_task(task_id) if task_id else None
|
||
if old and running(task_id):raise TaskError('任务正在执行,请等待结束后再修改。',409)
|
||
definition=validate_definition(payload);calendar_preview(definition)
|
||
if not old:
|
||
with db() as con:
|
||
if con.execute('SELECT COUNT(*) FROM tasks WHERE deleted=0').fetchone()[0]>=100:raise TaskError('最多管理 100 个任务。')
|
||
task_id=uuid.uuid4().hex
|
||
if old:
|
||
execute(['systemctl','disable','--now',unit_name(task_id)+'.timer'],check=False)
|
||
if running(task_id):
|
||
if old['enabled']:execute(['systemctl','enable','--now',unit_name(task_id)+'.timer'],check=False)
|
||
raise TaskError('任务刚刚开始执行,请等待完成后再修改。',409)
|
||
now=time.time();revision=old['revision']+1 if old else 1
|
||
try:
|
||
install_units(task_id,definition)
|
||
with db() as con:
|
||
con.execute('''INSERT INTO tasks VALUES(?,?,?,?,0,?,?,?) ON CONFLICT(id) DO UPDATE SET
|
||
name=excluded.name,definition=excluded.definition,enabled=excluded.enabled,
|
||
updated=excluded.updated,revision=excluded.revision''',
|
||
(task_id,definition['name'],json.dumps(definition,ensure_ascii=False),int(definition['enabled']),
|
||
old['created'] if old else now,now,revision))
|
||
if definition['enabled']:execute(['systemctl','enable','--now',unit_name(task_id)+'.timer'])
|
||
except Exception:
|
||
if old:
|
||
install_units(task_id,old['definition'])
|
||
with db() as con:
|
||
con.execute('UPDATE tasks SET name=?,definition=?,enabled=?,revision=? WHERE id=?',
|
||
(old['name'],json.dumps(old['definition'],ensure_ascii=False),old['enabled'],old['revision'],task_id))
|
||
if old['enabled']:execute(['systemctl','enable','--now',unit_name(task_id)+'.timer'],check=False)
|
||
else:
|
||
execute(['systemctl','disable','--now',unit_name(task_id)+'.timer'],check=False)
|
||
with db() as con:con.execute('DELETE FROM tasks WHERE id=?',(task_id,))
|
||
for suffix in ('.service','.timer'):(UNITS/(unit_name(task_id)+suffix)).unlink(missing_ok=True)
|
||
execute(['systemctl','daemon-reload'],check=False)
|
||
raise
|
||
audit('update' if old else 'create',task_id,client=client,detail='revision '+str(revision))
|
||
return {'id':task_id,'message':'任务已保存。'+('已启用,将按计划执行。' if definition['enabled'] else '当前停用,不会自动执行。')}
|
||
|
||
def set_enabled(task_id,enabled,client=''):
|
||
task=get_task(task_id)
|
||
if enabled:
|
||
schedule_definition(task['definition']['schedule'])
|
||
calendar_preview(task['definition'])
|
||
execute(['systemctl','enable' if enabled else 'disable','--now',unit_name(task_id)+'.timer'])
|
||
with db() as con:
|
||
definition=task['definition'];definition['enabled']=bool(enabled)
|
||
con.execute('UPDATE tasks SET enabled=?,definition=?,updated=? WHERE id=?',
|
||
(int(bool(enabled)),json.dumps(definition,ensure_ascii=False),time.time(),task_id))
|
||
audit('enable' if enabled else 'disable',task_id,client=client)
|
||
return {'message':'已启用。' if enabled else '已停用后续调度;已经开始的执行不会被强制中断。'}
|
||
|
||
def delete_task(task_id,client=''):
|
||
get_task(task_id)
|
||
if running(task_id):raise TaskError('任务正在执行,请先等待完成或停止本次执行。',409)
|
||
execute(['systemctl','disable','--now',unit_name(task_id)+'.timer'])
|
||
for suffix in ('.service','.timer'):
|
||
(UNITS/(unit_name(task_id)+suffix)).unlink(missing_ok=True)
|
||
execute(['systemctl','daemon-reload'])
|
||
with db() as con:con.execute('UPDATE tasks SET deleted=1,enabled=0,updated=? WHERE id=?',(time.time(),task_id))
|
||
audit('delete',task_id,client=client)
|
||
return {'message':'任务已删除,已有执行记录仍保留在服务器。'}
|
||
|
||
def run_now(task_id,client=''):
|
||
task=get_task(task_id)
|
||
if running(task_id):raise TaskError('这个任务正在执行,不会重复启动。',409)
|
||
with db() as con:
|
||
con.execute('INSERT OR REPLACE INTO requests VALUES(?,?,?)',(task_id,'manual',time.time()+30))
|
||
execute(['systemctl','reset-failed',unit_name(task_id)+'.service'],check=False)
|
||
execute(['systemctl','start','--no-block',unit_name(task_id)+'.service'])
|
||
audit('run_now',task_id,client=client,detail='revision '+str(task['revision']))
|
||
return {'message':'已提交执行,请在执行日志中查看结果。'}
|
||
|
||
def stop_task(task_id,client=''):
|
||
get_task(task_id)
|
||
execute(['systemctl','stop',unit_name(task_id)+'.service'])
|
||
audit('stop_run',task_id,client=client)
|
||
return {'message':'已请求停止本次执行。'}
|
||
|
||
def list_tasks():
|
||
p=execute(['systemctl','list-timers','--all','--output=json','--no-pager','hwjob-*.timer'],check=False)
|
||
try:timers={x['unit']:x for x in json.loads(p.stdout or '[]')}
|
||
except (ValueError,TypeError):timers={}
|
||
p=execute(['systemctl','list-units','--all','--type=service','--output=json','--no-pager','hwjob-*.service'],check=False)
|
||
try:active={x['unit']:x for x in json.loads(p.stdout or '[]')}
|
||
except (ValueError,TypeError):active={}
|
||
with db() as con:
|
||
tasks=con.execute('SELECT * FROM tasks WHERE deleted=0 ORDER BY created DESC').fetchall()
|
||
result=[]
|
||
for row in tasks:
|
||
item=dict(row);definition=json.loads(item.pop('definition'))
|
||
timer=timers.get(unit_name(item['id'])+'.timer',{})
|
||
next_us=timer.get('next')
|
||
item['next_run']=next_us/1e6 if isinstance(next_us,(int,float)) and 0<next_us<2**63 else None
|
||
item['running']=active.get(unit_name(item['id'])+'.service',{}).get('active') in ('active','activating','deactivating')
|
||
item['schedule_label']=definition['schedule_label'];item['mode']=definition['mode']
|
||
item['dangerous']=definition.get('dangerous',False);item['notes']=definition.get('notes','')
|
||
last=con.execute('SELECT * FROM runs WHERE task_id=? ORDER BY started DESC LIMIT 1',(item['id'],)).fetchone()
|
||
item['last_run']=dict(last) if last else None
|
||
result.append(item)
|
||
return {'tasks':result,'server_time':iso(),'timezone':'Asia/Shanghai'}
|
||
|
||
def runs_for(task_id):
|
||
get_task(task_id,True)
|
||
with db() as con:
|
||
rows=con.execute('SELECT * FROM runs WHERE task_id=? ORDER BY started DESC LIMIT 40',(task_id,)).fetchall()
|
||
return {'runs':[dict(row) for row in rows]}
|
||
|
||
def read_run(run_id):
|
||
validate_id(run_id)
|
||
with db() as con:row=con.execute('SELECT * FROM runs WHERE id=?',(run_id,)).fetchone()
|
||
if not row:raise TaskError('找不到这次执行记录。',404)
|
||
path=LOGS/(run_id+'.log')
|
||
try:
|
||
with path.open('rb') as f:
|
||
f.seek(0,2);size=f.tell();f.seek(max(0,size-131072));content=f.read().decode('utf-8',errors='replace')
|
||
except OSError:
|
||
content='本次输出已按保留规则清理,执行结果仍保留。' if row['state']!='running' else '日志尚未写入。'
|
||
size=0
|
||
return {'run':dict(row),'output':content,'tail_only':size>131072}
|