commit
7506c9395c
14 changed files with 1121 additions and 0 deletions
@ -0,0 +1 @@ |
|||||||
|
*.swp |
@ -0,0 +1,126 @@ |
|||||||
|
#!/usr/local/bin/bash |
||||||
|
|
||||||
|
install() { |
||||||
|
uninstall |
||||||
|
|
||||||
|
cat >"$launchd_file" << EOF |
||||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||||
|
<plist version="1.0"> |
||||||
|
<dict> |
||||||
|
<key>Label</key> |
||||||
|
<string>$launchd_name</string> |
||||||
|
<key>ProgramArguments</key> |
||||||
|
<array> |
||||||
|
<string>`which python3`</string> |
||||||
|
<string>$zasd_root/src/zasd.py</string> |
||||||
|
</array> |
||||||
|
<key>KeepAlive</key> |
||||||
|
<$keep_alive/> |
||||||
|
<key>StandardOutPath</key> |
||||||
|
<string>$log_root/zasd.log</string> |
||||||
|
<key>StandardErrorPath</key> |
||||||
|
<string>$log_root/zasd.err</string> |
||||||
|
</dict> |
||||||
|
</plist> |
||||||
|
EOF |
||||||
|
|
||||||
|
load |
||||||
|
} |
||||||
|
|
||||||
|
uninstall() { |
||||||
|
if is_installed; then |
||||||
|
unload |
||||||
|
fi |
||||||
|
|
||||||
|
rm -f "$launchd_file" |
||||||
|
} |
||||||
|
|
||||||
|
load() { |
||||||
|
launchctl load -w "$launchd_file" |
||||||
|
} |
||||||
|
|
||||||
|
unload() { |
||||||
|
launchctl unload -w "$launchd_file" |
||||||
|
} |
||||||
|
|
||||||
|
start() { |
||||||
|
launchctl kickstart -k $domain/$launchd_name |
||||||
|
} |
||||||
|
|
||||||
|
stop() { |
||||||
|
launchctl stop $launchd_name |
||||||
|
} |
||||||
|
|
||||||
|
is_installed() { |
||||||
|
launchctl list | fgrep -q $launchd_name |
||||||
|
} |
||||||
|
|
||||||
|
initialise() { |
||||||
|
if [ $UID == 0 ]; then |
||||||
|
launchd_file="/Library/LaunchDaemons/$launchd_name.plist" |
||||||
|
domain=system |
||||||
|
log_root="/var/log" |
||||||
|
else |
||||||
|
launchd_file="$HOME/Library/LaunchAgents/$launchd_name.plist" |
||||||
|
domain=gui/$UID |
||||||
|
log_root="$zasd_root/log" |
||||||
|
fi |
||||||
|
} |
||||||
|
|
||||||
|
zasd_root="$(dirname "$(greadlink -f "$0")")/.." |
||||||
|
|
||||||
|
launchd_name=no.thj.zasd |
||||||
|
|
||||||
|
keep_alive=true |
||||||
|
|
||||||
|
# Parse arguments |
||||||
|
while (($#)); do |
||||||
|
case $1 in |
||||||
|
-K|--keep-alive) |
||||||
|
keep_alive=true |
||||||
|
;; |
||||||
|
|
||||||
|
-k|--no-keep-alive) |
||||||
|
keep_alive=false |
||||||
|
;; |
||||||
|
|
||||||
|
*) |
||||||
|
command=$1 |
||||||
|
;; |
||||||
|
esac |
||||||
|
|
||||||
|
shift |
||||||
|
done |
||||||
|
|
||||||
|
initialise |
||||||
|
|
||||||
|
case $command in |
||||||
|
install) |
||||||
|
install |
||||||
|
;; |
||||||
|
|
||||||
|
uninstall) |
||||||
|
uninstall |
||||||
|
;; |
||||||
|
|
||||||
|
load) |
||||||
|
load |
||||||
|
;; |
||||||
|
|
||||||
|
unload) |
||||||
|
unload |
||||||
|
;; |
||||||
|
|
||||||
|
start|restart) |
||||||
|
start |
||||||
|
;; |
||||||
|
|
||||||
|
stop) |
||||||
|
stop |
||||||
|
;; |
||||||
|
|
||||||
|
*) |
||||||
|
echo "./zasctl [options] <install|load|start|restart|stop|unload|uninstall>" |
||||||
|
;; |
||||||
|
esac |
@ -0,0 +1,2 @@ |
|||||||
|
**/__pycache__ |
||||||
|
zasd.config.py |
@ -0,0 +1,72 @@ |
|||||||
|
from apscheduler.triggers.cron import CronTrigger |
||||||
|
import logging |
||||||
|
|
||||||
|
config = { |
||||||
|
# 'zfs_path': '/usr/local/bin/zfs', |
||||||
|
# 'fswatch_path': '/usr/local/bin/fswatch', |
||||||
|
|
||||||
|
# 'tab_size': 2, |
||||||
|
|
||||||
|
# 'log_level': logging.INFO, |
||||||
|
# 'log_format': '%(asctime)s %(name)s [%(levelname)-8s]: %(message)s', |
||||||
|
# 'log_date_format': '%a, %d %b %Y, %H:%M:%S', |
||||||
|
|
||||||
|
# 'separator': ':', |
||||||
|
# 'destroy_trigger': CronTrigger.from_crontab('* * * * *'), |
||||||
|
|
||||||
|
# 'defaults': { |
||||||
|
# 'disabled': False, |
||||||
|
# 'filesystems': ['Media', 'Volatile'], |
||||||
|
'recursive': True, |
||||||
|
'tag': '', |
||||||
|
'trigger': CronTrigger.from_crontab('0 0 * * *'), |
||||||
|
'priority': 1, |
||||||
|
'keep': 6 |
||||||
|
}, |
||||||
|
|
||||||
|
'schedules': [ |
||||||
|
{ |
||||||
|
'disabled': True, |
||||||
|
'tag': '1mi', |
||||||
|
'trigger': CronTrigger.from_crontab('* * * * *'), |
||||||
|
'priority': 7, |
||||||
|
'keep': 4 |
||||||
|
}, |
||||||
|
{ |
||||||
|
'tag': '5mi', |
||||||
|
'trigger': CronTrigger.from_crontab('*/5 * * * *'), |
||||||
|
'priority': 6, |
||||||
|
'keep': 4 |
||||||
|
}, |
||||||
|
{ |
||||||
|
'tag': '20m', |
||||||
|
'trigger': CronTrigger.from_crontab('*/20 * * * *'), |
||||||
|
'priority': 5, |
||||||
|
'keep': 3 |
||||||
|
}, |
||||||
|
{ |
||||||
|
'tag': '1hr', |
||||||
|
'trigger': CronTrigger.from_crontab('0 * * * *'), |
||||||
|
'priority': 4, |
||||||
|
'keep': 6 |
||||||
|
}, |
||||||
|
{ |
||||||
|
'tag': '6hr', |
||||||
|
'trigger': CronTrigger.from_crontab('0 */6 * * *'), |
||||||
|
'priority': 3, |
||||||
|
'keep': 4 |
||||||
|
}, |
||||||
|
{ |
||||||
|
'tag': '1dy', |
||||||
|
'trigger': CronTrigger.from_crontab('0 0 * * *'), |
||||||
|
'priority': 2, |
||||||
|
'keep': 7 |
||||||
|
}, |
||||||
|
{ |
||||||
|
'tag': '1wk', |
||||||
|
'trigger': CronTrigger.from_crontab('0 0 * * mon'), |
||||||
|
'priority': 1, |
||||||
|
'keep': 4 |
||||||
|
} |
||||||
|
] |
||||||
|
} |
@ -0,0 +1,69 @@ |
|||||||
|
#zfs_path = '/usr/local/bin/zfs' |
||||||
|
#fswatch_path = '/usr/local/bin/fswatch' |
||||||
|
|
||||||
|
# Tab size for indented log messages |
||||||
|
#tab_size = 2 |
||||||
|
|
||||||
|
# Log format |
||||||
|
#log_level = INFO |
||||||
|
#log_format = '%(asctime)s %(name)s [%(levelname)-8s]: %(message)s' |
||||||
|
#log_date_format = '%a, %d %b %Y, %H:%M:%S' |
||||||
|
|
||||||
|
# Field separator in snapshot names (':' gives 'dataset@tag:serial') |
||||||
|
#separator = ':' |
||||||
|
|
||||||
|
# How frequently to prune expired snapshots |
||||||
|
#destroy_trigger = cron('* * * * *') |
||||||
|
|
||||||
|
# Default settings for a schedule |
||||||
|
#defaults = dict( |
||||||
|
# tag = 'zasd', |
||||||
|
# disabled = False, |
||||||
|
# filesystems = ['tank'], |
||||||
|
# recursive = True, |
||||||
|
# trigger = every(hours=12), |
||||||
|
# priority = 1, |
||||||
|
# keep = 14) |
||||||
|
|
||||||
|
# The simplest possible schedule list possible sets up a single |
||||||
|
# schedule with the default settings |
||||||
|
#schedules = [{}] |
||||||
|
|
||||||
|
# More advanced example schedule |
||||||
|
# |
||||||
|
schedules = [ |
||||||
|
dict( |
||||||
|
tag = '5mi', |
||||||
|
trigger = cron('*/5 * * * *'), |
||||||
|
priority = 6, |
||||||
|
keep = 4), |
||||||
|
|
||||||
|
dict( |
||||||
|
tag = '20m', |
||||||
|
trigger = cron('*/20 * * * *'), |
||||||
|
priority = 5, |
||||||
|
keep = 3), |
||||||
|
|
||||||
|
dict( |
||||||
|
tag = '1hr', |
||||||
|
trigger = cron('0 * * * *'), |
||||||
|
priority = 4, |
||||||
|
keep = 6), |
||||||
|
|
||||||
|
dict( |
||||||
|
tag = '6hr', |
||||||
|
trigger = cron('0 */6 * * *'), |
||||||
|
priority = 3, |
||||||
|
keep = 4), |
||||||
|
|
||||||
|
dict( |
||||||
|
tag = '1dy', |
||||||
|
trigger = cron('0 0 * * *'), |
||||||
|
priority = 2, |
||||||
|
keep = 7), |
||||||
|
|
||||||
|
dict( |
||||||
|
tag = '1wk', |
||||||
|
trigger = cron('0 0 * * mon'), |
||||||
|
priority = 1, |
||||||
|
keep = 4)] |
@ -0,0 +1,357 @@ |
|||||||
|
from sys import stdout, stderr |
||||||
|
|
||||||
|
import signal |
||||||
|
import time |
||||||
|
from functools import partial, reduce |
||||||
|
from itertools import islice |
||||||
|
import asyncio |
||||||
|
from subprocess import run, PIPE |
||||||
|
from datetime import datetime, timezone, timedelta |
||||||
|
import logging |
||||||
|
|
||||||
|
from apscheduler.triggers.cron import CronTrigger |
||||||
|
from apscheduler.triggers.interval import IntervalTrigger |
||||||
|
|
||||||
|
from zasd.apscheduler import * |
||||||
|
from zasd.asyncio import * |
||||||
|
from zasd.config import * |
||||||
|
|
||||||
|
# |
||||||
|
# Constants |
||||||
|
|
||||||
|
DATASET_COLS = ['type', 'name', 'creation', 'mountpoint'] |
||||||
|
|
||||||
|
# |
||||||
|
# Functions for running subprocesses with tabulated output |
||||||
|
|
||||||
|
# Run program and convert tabulated output to nested lists |
||||||
|
def run_for_table(args): |
||||||
|
result = run(args, check=True, stdout=PIPE, encoding='utf-8') |
||||||
|
return str_to_table(result.stdout) |
||||||
|
|
||||||
|
# Run program and convert tabulated output to list of dictionaries with given column names as keys |
||||||
|
def run_for_dicts(args, column_list): |
||||||
|
return table_to_dicts(run_for_table(args), column_list) |
||||||
|
|
||||||
|
# |
||||||
|
# Functions for converting multi-line tabulated strings to data structures |
||||||
|
|
||||||
|
# Convert tabulated multi-line string to nested lists |
||||||
|
def str_to_table(string, sep='\t'): |
||||||
|
return list(line.split(sep) for line in string.splitlines()) |
||||||
|
|
||||||
|
# Convert table to list of dictionaries with given column names as keys |
||||||
|
def table_to_dicts(table, column_list): |
||||||
|
return list(row_to_dict(row, column_list) for row in table) |
||||||
|
|
||||||
|
# Convert table row to dictionary with given column names as keys |
||||||
|
def row_to_dict(row, column_list): |
||||||
|
return ({ column_list[i]: row[i] for i in range(len(row)) }) |
||||||
|
|
||||||
|
# |
||||||
|
# ZFS functions |
||||||
|
|
||||||
|
# Get list of snapshots (dataset dictionaries) |
||||||
|
def zfs_get_snapshots(): |
||||||
|
return zfs_get_datasets('snapshot') |
||||||
|
|
||||||
|
# Get list of filesystems (dataset dictionaries) |
||||||
|
def zfs_get_filesystems(): |
||||||
|
return zfs_get_datasets('filesystem') |
||||||
|
|
||||||
|
# Get list of datasets |
||||||
|
def zfs_get_datasets(dataset_type='all'): |
||||||
|
global config |
||||||
|
return zfs_dicts_to_datasets(run_for_dicts( |
||||||
|
[config['zfs_path'], |
||||||
|
'list', |
||||||
|
'-Hp', |
||||||
|
'-t', dataset_type, |
||||||
|
'-o', ','.join(DATASET_COLS)], DATASET_COLS)) |
||||||
|
|
||||||
|
# Transform list of ZFS dictionaries to list of datasets |
||||||
|
def zfs_dicts_to_datasets(dicts): |
||||||
|
return list(zfs_dict_to_dataset(d) for d in dicts) |
||||||
|
|
||||||
|
# Transform dictionary to dataset (pool, filesystem) |
||||||
|
def zfs_dict_to_dataset(zfs_dict): |
||||||
|
name = zfs_dict['name'] |
||||||
|
dataset = dict(zfs_dict) |
||||||
|
|
||||||
|
# Separate dataset and snapshot names out to extra fields |
||||||
|
if '@' in name: |
||||||
|
fields = name.split('@') |
||||||
|
dataset['dataset'] = fields[0] |
||||||
|
dataset['snapshot'] = fields[1] |
||||||
|
|
||||||
|
return dataset |
||||||
|
|
||||||
|
# Create one or more snapshots |
||||||
|
async def zfs_create_snapshot(*snapshots, recursive=False): |
||||||
|
global config |
||||||
|
|
||||||
|
args = [config['zfs_path'], 'snapshot'] |
||||||
|
if recursive: |
||||||
|
args.append('-r') |
||||||
|
|
||||||
|
for snapshot in snapshots: |
||||||
|
sargs = args + [get_snapshot_zfs_name(snapshot)] |
||||||
|
await asyncio.create_subprocess_exec(*sargs) |
||||||
|
|
||||||
|
# Destroy one or more snapshots |
||||||
|
async def zfs_destroy_snapshot(*snapshots, recursive=False): |
||||||
|
global config |
||||||
|
|
||||||
|
args = [config['zfs_path'], 'destroy'] |
||||||
|
if recursive: |
||||||
|
args.append('-r') |
||||||
|
|
||||||
|
for snapshot in snapshots: |
||||||
|
sargs = args + [get_snapshot_zfs_name(snapshot)] |
||||||
|
await asyncio.create_subprocess_exec(*sargs) |
||||||
|
|
||||||
|
# Generate ZFS identifier string for snapshot |
||||||
|
def get_snapshot_zfs_name(snapshot): |
||||||
|
if 'tag' in snapshot: |
||||||
|
return make_snapshot_zfs_name(snapshot['dataset'], snapshot['tag'], snapshot.get('serial', None)) |
||||||
|
elif 'snapshot' in snapshot: |
||||||
|
return make_snapshot_zfs_name(snapshot['dataset'], snapshot['snapshot']) |
||||||
|
else: |
||||||
|
raise KeyError('Snapshot has no name or tag') |
||||||
|
|
||||||
|
# Generate ZFS identifier string from arguments |
||||||
|
def make_snapshot_zfs_name(dataset, tag_or_snapshot, serial=None): |
||||||
|
if serial is None: |
||||||
|
return '{}@{}'.format(dataset, tag_or_snapshot) |
||||||
|
else: |
||||||
|
return '{}@{}:{}'.format(dataset, tag_or_snapshot, serial) |
||||||
|
# |
||||||
|
# Configuration functions |
||||||
|
|
||||||
|
# Retrieve all schedules and merge with default schedule |
||||||
|
def get_schedules(): |
||||||
|
global config |
||||||
|
schedules = ({**config['defaults'], **dict(s)} for s in config['schedules']) |
||||||
|
return schedules |
||||||
|
|
||||||
|
# Get dictionary of tag-modified flags on filesystem |
||||||
|
def get_fs_flags(name): |
||||||
|
global fs_modified |
||||||
|
|
||||||
|
if not name in fs_modified: |
||||||
|
fs_modified[name] = dict() |
||||||
|
|
||||||
|
return fs_modified[name] |
||||||
|
|
||||||
|
# Get tag-modified flag for specific tag on filesystem |
||||||
|
def get_fs_flag(name, tag): |
||||||
|
flags = get_fs_flags(name) |
||||||
|
|
||||||
|
if not tag in flags: |
||||||
|
flags[tag] = False |
||||||
|
|
||||||
|
return flags[tag] |
||||||
|
|
||||||
|
# Set specific tag-modified flag on filesystem |
||||||
|
def set_fs_flag(name, tag): |
||||||
|
flags = get_fs_flags(name) |
||||||
|
flags[tag] = True |
||||||
|
|
||||||
|
# Set all tag-modified flags on filesystem |
||||||
|
def set_all_fs_flags(name): |
||||||
|
flags = get_fs_flags(name) |
||||||
|
for tag in flags.keys(): |
||||||
|
set_fs_flag(name, tag) |
||||||
|
|
||||||
|
# Clear specific tag-modified flag on filesystem |
||||||
|
def clear_fs_flag(name, tag): |
||||||
|
flags = get_fs_flags(name) |
||||||
|
flags[tag] = False |
||||||
|
|
||||||
|
# |
||||||
|
# fswatch subprocess protocol for asyncio |
||||||
|
|
||||||
|
class FSWatchProtocol(LineBufferedProtocol): |
||||||
|
def __init__(self, fs): |
||||||
|
LineBufferedProtocol.__init__(self, 'utf-8') |
||||||
|
self.fs = fs |
||||||
|
|
||||||
|
def pipe_line_received(self, line): |
||||||
|
global logger |
||||||
|
|
||||||
|
# Ignore empty lines and NOOPs |
||||||
|
if len(line) == 0 or int(line) == 0: |
||||||
|
return |
||||||
|
|
||||||
|
logger.info('Detected change on filesystem %s', self.fs['name']) |
||||||
|
|
||||||
|
# Set all tag-modified flags on filesystem |
||||||
|
set_all_fs_flags(self.fs['name']) |
||||||
|
|
||||||
|
# |
||||||
|
# Snapshot scheduling functions |
||||||
|
|
||||||
|
# Create snapshot from a snapshot schedule |
||||||
|
async def snapshot_creation_task(schedule, fs): |
||||||
|
global logger |
||||||
|
|
||||||
|
tag = schedule['tag'] |
||||||
|
serial = make_snapshot_serial() |
||||||
|
recursive = schedule['recursive'] |
||||||
|
|
||||||
|
if get_fs_flag(fs, tag): |
||||||
|
# Clear tag-modified flags for this tag on filesystem |
||||||
|
clear_fs_flag(fs, tag) |
||||||
|
|
||||||
|
logger.info('Taking snapshot of filesystem %s on schedule %s', fs, tag) |
||||||
|
|
||||||
|
# Create stub snapshot record and take the snapshot |
||||||
|
snapshot = dict(dataset=fs, tag=tag, serial=serial) |
||||||
|
await zfs_create_snapshot(snapshot, recursive=recursive) |
||||||
|
|
||||||
|
# Generate time-based 8-character hexadecimal snapshot serial number |
||||||
|
def make_snapshot_serial(): |
||||||
|
return ('%x' % int(time.time()))[-8:] |
||||||
|
|
||||||
|
# Destroy all expired snapshots |
||||||
|
async def snapshot_destruction_task(): |
||||||
|
global config, logger |
||||||
|
|
||||||
|
snapshots = zfs_get_snapshots() |
||||||
|
|
||||||
|
for schedule in get_schedules(): |
||||||
|
if schedule['disabled']: |
||||||
|
continue |
||||||
|
|
||||||
|
# Find expired snapshots for schedule |
||||||
|
tag = schedule['tag'] |
||||||
|
expired = slice_snapshots(snapshots, tag, index=schedule['keep'], stop=None, reverse=True) |
||||||
|
|
||||||
|
if len(expired) > 0: |
||||||
|
logger.info('Destroying snapsnots with tag %s:', tag) |
||||||
|
for snapshot in expired: |
||||||
|
logger.info('%s%s', config['tab_size'] * ' ', snapshot['name']) |
||||||
|
await zfs_destroy_snapshot(snapshot) |
||||||
|
|
||||||
|
# Check if snapshot matches tag |
||||||
|
def snapshot_matches_tag(snapshot, tag): |
||||||
|
return get_snapshot_tag(snapshot) == tag |
||||||
|
|
||||||
|
# Get tag of snapshot |
||||||
|
def get_snapshot_tag(snapshot): |
||||||
|
(tag, serial) = get_snapshot_fields(snapshot) |
||||||
|
return tag |
||||||
|
|
||||||
|
# Get serial number of snapshot |
||||||
|
def get_snapshot_serial(snapshot): |
||||||
|
(tag, serial) = get_snapshot_fields(snapshot) |
||||||
|
return serial |
||||||
|
|
||||||
|
# Get tuple of fields in snapshot name |
||||||
|
def get_snapshot_fields(snapshot): |
||||||
|
global config |
||||||
|
return tuple(snapshot['snapshot'].split(config['separator'])) |
||||||
|
|
||||||
|
# Group 'snapshots' list using 'key' function, enumerate groups (in reverse if 'reverse' is |
||||||
|
# True), slice by 'index' and 'stop', and return slice as flat list of snapshots |
||||||
|
# |
||||||
|
# If 'stop' is not specified, assume that 'index' is the index to shop at; otherwise, slice |
||||||
|
# beginning at 'index' and ending at 'stop' |
||||||
|
# |
||||||
|
def slice_snapshots(snapshots, tag, index, stop=0, reverse=False, key=get_snapshot_serial): |
||||||
|
# Find matching snapshots |
||||||
|
matches = list(s for s in snapshots if snapshot_matches_tag(s, tag)) |
||||||
|
|
||||||
|
# Make ordered set of serials present in matched snapshots |
||||||
|
ordered_set = list(sorted(set(key(s) for s in matches), reverse=reverse)) |
||||||
|
|
||||||
|
# Slice n serials from ordered set of serials |
||||||
|
serials = ordered_set[:index] if stop == 0 else ordered_set[index:stop] |
||||||
|
|
||||||
|
# Intersect matching snapshots with sliced set of serials |
||||||
|
result = list(s for s in matches if get_snapshot_serial(s) in set(serials)) |
||||||
|
|
||||||
|
return result |
||||||
|
|
||||||
|
# Load and activate snapshot schedules |
||||||
|
def load_snapshot_schedules(): |
||||||
|
global config, scheduler, fs_modified, logger |
||||||
|
|
||||||
|
fs_modified = dict() |
||||||
|
|
||||||
|
for schedule in get_schedules(): |
||||||
|
if schedule['disabled']: |
||||||
|
continue |
||||||
|
|
||||||
|
tag = schedule['tag'] |
||||||
|
for fs in schedule['filesystems']: |
||||||
|
scheduler.add_job(snapshot_creation_task, |
||||||
|
trigger = schedule['trigger'], |
||||||
|
id = make_snapshot_zfs_name(fs, tag), |
||||||
|
group = fs, |
||||||
|
priority = schedule['priority'], |
||||||
|
args = [schedule, fs]) |
||||||
|
|
||||||
|
# Set tag-modified flags on filesystems (always take snapshots on startup) |
||||||
|
for name in schedule['filesystems']: |
||||||
|
set_fs_flag(name, tag) |
||||||
|
|
||||||
|
scheduler.add_job(snapshot_destruction_task, |
||||||
|
trigger = config['destroy_trigger'], |
||||||
|
group = 'destroy') |
||||||
|
|
||||||
|
async def main_task(): |
||||||
|
global config, event_loop, scheduler, fs_listeners |
||||||
|
|
||||||
|
|
||||||
|
scheduler = AsyncIOPriorityScheduler( |
||||||
|
event_loop = event_loop, |
||||||
|
executors = {'default': AsyncIOPriorityExecutor()}) |
||||||
|
|
||||||
|
# Watch file system mountpoints |
||||||
|
fs_listeners = dict() |
||||||
|
for fs in zfs_get_filesystems(): |
||||||
|
await event_loop.subprocess_exec( |
||||||
|
lambda: FSWatchProtocol(fs), config['fswatch_path'], '-o', fs['mountpoint'], stdout=PIPE) |
||||||
|
|
||||||
|
load_snapshot_schedules() |
||||||
|
|
||||||
|
scheduler.start() |
||||||
|
|
||||||
|
if stdout.isatty(): |
||||||
|
while True: |
||||||
|
print_spinner() |
||||||
|
await asyncio.sleep(1) |
||||||
|
|
||||||
|
# Print idle spinner |
||||||
|
def print_spinner(): |
||||||
|
print(print_spinner.chars[print_spinner.index] + '\x1B[G', end='', file=stderr, flush=True) |
||||||
|
print_spinner.index = (print_spinner.index + 1) % len(print_spinner.chars) |
||||||
|
print_spinner.index = 0 |
||||||
|
print_spinner.chars = ['|', '/', '-', '\\'] |
||||||
|
|
||||||
|
def signal_handler(signame): |
||||||
|
global logger, event_loop |
||||||
|
logger.info('Received %s', signame) |
||||||
|
event_loop.stop() |
||||||
|
|
||||||
|
# |
||||||
|
# Program |
||||||
|
|
||||||
|
config = load_config() |
||||||
|
configure_logging(config) |
||||||
|
|
||||||
|
logger.info('Processing jobs') |
||||||
|
|
||||||
|
event_loop = asyncio.get_event_loop() |
||||||
|
event_loop.add_signal_handler(signal.SIGINT, partial(signal_handler, 'SIGINT')) |
||||||
|
event_loop.add_signal_handler(signal.SIGTERM, partial(signal_handler, 'SIGTERM')) |
||||||
|
|
||||||
|
event_loop.create_task(main_task()) |
||||||
|
|
||||||
|
try: |
||||||
|
event_loop.run_forever() |
||||||
|
finally: |
||||||
|
logger.info('Terminating') |
||||||
|
print(file=stderr) |
||||||
|
event_loop.close() |
@ -0,0 +1,236 @@ |
|||||||
|
import logging |
||||||
|
from datetime import datetime |
||||||
|
|
||||||
|
import six |
||||||
|
from apscheduler.job import Job |
||||||
|
from apscheduler.util import undefined |
||||||
|
from apscheduler.schedulers.base import STATE_STOPPED |
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler |
||||||
|
from apscheduler.executors.asyncio import AsyncIOExecutor |
||||||
|
from apscheduler.events import EVENT_JOB_EXECUTED, EVENT_JOB_MAX_INSTANCES, EVENT_JOB_ERROR, EVENT_JOB_MISSED |
||||||
|
|
||||||
|
class AsyncIOPriorityScheduler(AsyncIOScheduler): |
||||||
|
def add_job(self, func, trigger=None, args=None, kwargs=None, id=None, name=None, |
||||||
|
misfire_grace_time=undefined, coalesce=undefined, max_instances=undefined, |
||||||
|
next_run_time=undefined, jobstore='default', executor='default', |
||||||
|
replace_existing=False, group=0, priority=0, **trigger_args): |
||||||
|
job_kwargs = { |
||||||
|
'trigger': self._create_trigger(trigger, trigger_args), |
||||||
|
'executor': executor, |
||||||
|
'func': func, |
||||||
|
'args': tuple(args) if args is not None else (), |
||||||
|
'kwargs': dict(kwargs) if kwargs is not None else {}, |
||||||
|
'id': id, |
||||||
|
'name': name, |
||||||
|
'misfire_grace_time': misfire_grace_time, |
||||||
|
'coalesce': coalesce, |
||||||
|
'max_instances': max_instances, |
||||||
|
'next_run_time': next_run_time, |
||||||
|
'group': group, |
||||||
|
'priority': priority |
||||||
|
} |
||||||
|
job_kwargs = dict((key, value) for key, value in job_kwargs.items() if |
||||||
|
value is not undefined) |
||||||
|
job = _PriorityJob(self, **job_kwargs) |
||||||
|
|
||||||
|
# Don't really add jobs to job stores before the scheduler is up and running |
||||||
|
with self._jobstores_lock: |
||||||
|
if self.state == STATE_STOPPED: |
||||||
|
self._pending_jobs.append((job, jobstore, replace_existing)) |
||||||
|
self._logger.info('Adding job tentatively -- it will be properly scheduled when ' |
||||||
|
'the scheduler starts') |
||||||
|
else: |
||||||
|
self._real_add_job(job, jobstore, replace_existing) |
||||||
|
|
||||||
|
return job |
||||||
|
|
||||||
|
def get_due_jobs(self, now=None): |
||||||
|
if now is None: |
||||||
|
now = datetime.now(self.timezone) |
||||||
|
|
||||||
|
with self._jobstores_lock: |
||||||
|
jobs = [] |
||||||
|
for alias, store in six.iteritems(self._jobstores): |
||||||
|
jobs.extend(store.get_due_jobs(now)) |
||||||
|
|
||||||
|
return jobs |
||||||
|
|
||||||
|
def _process_jobs(self): |
||||||
|
due_jobs = self.get_due_jobs() |
||||||
|
wait_seconds = super()._process_jobs() |
||||||
|
|
||||||
|
executors = [] |
||||||
|
for job in due_jobs: |
||||||
|
executor = self._lookup_executor(job.executor) |
||||||
|
if not executor in executors: |
||||||
|
executors.append(executor) |
||||||
|
|
||||||
|
for executor in executors: |
||||||
|
if hasattr(executor, 'commit'): |
||||||
|
executor.commit() |
||||||
|
|
||||||
|
return wait_seconds |
||||||
|
|
||||||
|
class _PriorityJob(Job): |
||||||
|
def __init__(self, scheduler, priority=1, group=1, **kwargs): |
||||||
|
self.priority = priority |
||||||
|
self.group = group |
||||||
|
super().__init__(scheduler, **kwargs) |
||||||
|
|
||||||
|
def modify(self, **changes): |
||||||
|
if 'priority' in changes: |
||||||
|
self.priority = changes.pop('priority') |
||||||
|
|
||||||
|
if 'group' in changes: |
||||||
|
self.group = changes.pop('group') |
||||||
|
|
||||||
|
super().modify(self, **changes) |
||||||
|
|
||||||
|
def __getstate__(self): |
||||||
|
state = super().__getstate__() |
||||||
|
|
||||||
|
state['priority'] = self.priority |
||||||
|
state['group'] = self.group |
||||||
|
|
||||||
|
return state |
||||||
|
|
||||||
|
def __setstate__(self, state): |
||||||
|
priority = state.pop('priority') |
||||||
|
group = state.pop('group') |
||||||
|
|
||||||
|
super().__setstate__(state) |
||||||
|
|
||||||
|
self.priority = priority |
||||||
|
self.group = group |
||||||
|
|
||||||
|
class AsyncIOPriorityExecutor(AsyncIOExecutor): |
||||||
|
def __init__(self, *args, **kwargs): |
||||||
|
self.scheduler = None |
||||||
|
self.job_groups = {} |
||||||
|
|
||||||
|
super().__init__(*args, **kwargs) |
||||||
|
|
||||||
|
def start(self, scheduler, alias): |
||||||
|
# Store a reference to the scheduler and add job listener |
||||||
|
self.scheduler = scheduler |
||||||
|
self.scheduler.add_listener(self.on_job_executed, mask=EVENT_JOB_EXECUTED|EVENT_JOB_MAX_INSTANCES|EVENT_JOB_ERROR|EVENT_JOB_MISSED) |
||||||
|
|
||||||
|
super().start(scheduler, alias) |
||||||
|
|
||||||
|
def shutdown(self, *args): |
||||||
|
# Remove job listener |
||||||
|
self.scheduler.remove_listener(self.on_job_executed) |
||||||
|
|
||||||
|
super().shutdown(*args) |
||||||
|
|
||||||
|
def submit_job(self, job, run_times): |
||||||
|
run_times = set(run_times) |
||||||
|
|
||||||
|
logger = logging.getLogger('zasd') |
||||||
|
logger.debug('Incoming submission for job %s with run times:', job.id) |
||||||
|
for time in run_times: |
||||||
|
logger.debug(' %s', time) |
||||||
|
|
||||||
|
if job.group in self.job_groups: |
||||||
|
job_group = self.job_groups[job.group] |
||||||
|
|
||||||
|
if job.id in job_group['times_for_job']: |
||||||
|
# Fetch time set |
||||||
|
times_for_job = job_group['times_for_job'][job.id] |
||||||
|
else: |
||||||
|
# Create and store time set |
||||||
|
times_for_job = set() |
||||||
|
job_group['times_for_job'][job.id] = times_for_job |
||||||
|
|
||||||
|
# Fetch map of times to their jobs |
||||||
|
job_for_time = job_group['job_for_time'] |
||||||
|
|
||||||
|
# Filter out run times that coincide with higher-priority jobs |
||||||
|
run_times = set( |
||||||
|
time for time in run_times if |
||||||
|
not time in job_for_time or |
||||||
|
job.priority < self.scheduler.get_job(job_for_time[time]).priority |
||||||
|
) |
||||||
|
|
||||||
|
else: |
||||||
|
# Create and store empty job group |
||||||
|
|
||||||
|
times_for_job = set() |
||||||
|
job_for_time = {} |
||||||
|
job_group = { 'times_for_job': { job.id: times_for_job }, 'job_for_time': job_for_time } |
||||||
|
|
||||||
|
self.job_groups[job.group] = job_group |
||||||
|
|
||||||
|
# Add new times to stored time set for current job |
||||||
|
times_for_job |= run_times |
||||||
|
|
||||||
|
# Remove jobs in time set from other jobs in group |
||||||
|
# Necessary when incoming job overwrites time slots for existing jobs |
||||||
|
# |
||||||
|
# Look at time sets for all jobs in group |
||||||
|
for job_id, times in list(job_group['times_for_job'].items()): |
||||||
|
# Look at time set for this job |
||||||
|
for time in set(times): |
||||||
|
# Does time in set coincide with time set of job being submitted? |
||||||
|
if time in times_for_job and job_id != job.id: |
||||||
|
# Remove time from time set |
||||||
|
times.remove(time) |
||||||
|
|
||||||
|
if len(times) == 0: |
||||||
|
# Delete time set if empty |
||||||
|
del job_group['times_for_job'][job_id] |
||||||
|
else: |
||||||
|
# Update time set if not |
||||||
|
job_group['times_for_job'][job_id] = times |
||||||
|
|
||||||
|
logger.debug('Final time set for job %s:', job.id) |
||||||
|
for time in times_for_job: |
||||||
|
logger.debug(' %s', time) |
||||||
|
|
||||||
|
# Map run times to jobs |
||||||
|
for time in run_times: |
||||||
|
job_for_time[time] = job.id |
||||||
|
|
||||||
|
def commit(self): |
||||||
|
logger = logging.getLogger('zasd') |
||||||
|
|
||||||
|
logger.debug('Committing jobs:') |
||||||
|
# Look at every group |
||||||
|
for group_id, group in self.job_groups.items(): |
||||||
|
# Look at every job in group |
||||||
|
for job_id, times in group['times_for_job'].items(): |
||||||
|
# Find job for ID and submit to scheduler superclass |
||||||
|
job = self.scheduler.get_job(job_id) |
||||||
|
super().submit_job(job, list(times)) |
||||||
|
|
||||||
|
logger.debug(' %s', job.id) |
||||||
|
for time in times: |
||||||
|
logger.debug(' %s', time) |
||||||
|
|
||||||
|
def on_job_executed(self, event): |
||||||
|
time = event.scheduled_run_time |
||||||
|
|
||||||
|
logger = logging.getLogger('zasd') |
||||||
|
logger.debug('Job %s has executed', event.job_id) |
||||||
|
|
||||||
|
# Find job for job ID |
||||||
|
job = self.scheduler.get_job(event.job_id) |
||||||
|
|
||||||
|
# Get group and times for job |
||||||
|
job_group = self.job_groups[job.group] |
||||||
|
times_for_job = job_group['times_for_job'] |
||||||
|
job_for_time = job_group['job_for_time'] |
||||||
|
|
||||||
|
# Delete job from time map |
||||||
|
del job_for_time[time] |
||||||
|
|
||||||
|
# Delte time from job time set |
||||||
|
times_for_job[job.id].remove(time) |
||||||
|
|
||||||
|
if len(times_for_job[job.id]) == 0: |
||||||
|
logger.debug('Deleting empty time set for job %s', job.id) |
||||||
|
del times_for_job[job.id] |
||||||
|
else: |
||||||
|
logger.debug('Remaining time set for job %s:', job.id) |
||||||
|
for time in times_for_job[job.id]: |
||||||
|
logger.debug(' %s', time) |
@ -0,0 +1,30 @@ |
|||||||
|
import asyncio |
||||||
|
|
||||||
|
class LineBufferedProtocol(asyncio.SubprocessProtocol): |
||||||
|
def __init__(self, encoding): |
||||||
|
self.encoding = encoding |
||||||
|
self.buffer = bytearray() |
||||||
|
|
||||||
|
def pipe_data_received(self, fd, data): |
||||||
|
self.buffer.extend(data) |
||||||
|
|
||||||
|
try: |
||||||
|
while True: |
||||||
|
length = self.buffer.index(b'\n') |
||||||
|
|
||||||
|
line = self.buffer[0: length].decode() |
||||||
|
del self.buffer[0: length + 1] |
||||||
|
|
||||||
|
self.pipe_line_received(line) |
||||||
|
|
||||||
|
except ValueError: |
||||||
|
pass |
||||||
|
|
||||||
|
def pipe_line_received(self, line): |
||||||
|
pass |
||||||
|
|
||||||
|
def process_exited(self): |
||||||
|
if(len(self.buffer) > 0): |
||||||
|
line = self.buffer.decode() |
||||||
|
self.buffer = bytearray() |
||||||
|
self.pipe_line_received(line) |
@ -0,0 +1,171 @@ |
|||||||
|
from sys import argv, executable |
||||||
|
from os import environ, getcwd, sep as psep |
||||||
|
from os.path import dirname, abspath, join as joinpath, \ |
||||||
|
expanduser, splitdrive, isfile |
||||||
|
|
||||||
|
from time import sleep |
||||||
|
|
||||||
|
from copy import deepcopy |
||||||
|
|
||||||
|
from apscheduler.triggers.cron import CronTrigger |
||||||
|
from apscheduler.triggers.interval import IntervalTrigger |
||||||
|
|
||||||
|
import logging |
||||||
|
import pprint |
||||||
|
|
||||||
|
from zasd.logging import * |
||||||
|
from zasd.util import * |
||||||
|
|
||||||
|
# |
||||||
|
# Constants |
||||||
|
|
||||||
|
CONFIG_BASENAME = 'zasd.conf.py' |
||||||
|
CONFIG_FILENAMES = [CONFIG_BASENAME, '.' + CONFIG_BASENAME] |
||||||
|
|
||||||
|
# Default configuration |
||||||
|
DEFAULT_CONFIG = { |
||||||
|
'zfs_path': '/usr/local/bin/zfs', |
||||||
|
'fswatch_path': '/usr/local/bin/fswatch', |
||||||
|
|
||||||
|
'tab_size': 2, |
||||||
|
|
||||||
|
'log_level': logging.INFO, |
||||||
|
'log_format': '%(asctime)s %(name)s [%(levelname)-8s]: %(message)s', |
||||||
|
'log_date_format': '%a, %d %b %Y, %H:%M:%S', |
||||||
|
|
||||||
|
'separator': ':', |
||||||
|
'destroy_trigger': CronTrigger.from_crontab('* * * * *'), |
||||||
|
|
||||||
|
# Defaults will take a snapshot every 12 hours |
||||||
|
# and keep them for a week, so if the config file |
||||||
|
# should disappear for some reason, there will |
||||||
|
# at least be _some_ recoverable snapshots |
||||||
|
'defaults': { |
||||||
|
'disabled': False, |
||||||
|
'filesystems': ['tank'], |
||||||
|
'recursive': True, |
||||||
|
'tag': 'zasd', |
||||||
|
'trigger': IntervalTrigger(hours=12), |
||||||
|
'priority': 1, |
||||||
|
'keep': 14 |
||||||
|
}, |
||||||
|
|
||||||
|
'schedules': [{}] |
||||||
|
} |
||||||
|
|
||||||
|
# Load configuration |
||||||
|
def load_config(): |
||||||
|
global logger |
||||||
|
|
||||||
|
if len(argv) > 1: |
||||||
|
# Configuration pathname given as first argument |
||||||
|
if isfile(argv[1]): |
||||||
|
config_pathname = argv[1] |
||||||
|
else: |
||||||
|
logger.warning('Could not find configuration file %s', argv[1]) |
||||||
|
return _warn_load_default() |
||||||
|
else: |
||||||
|
# No configuration pathname given; attempt to find it: |
||||||
|
|
||||||
|
# Get root of system partition |
||||||
|
sys_root_path = environ.get('SystemDrive', splitdrive(executable)[0] + psep) |
||||||
|
|
||||||
|
# Get system configuration directory |
||||||
|
sys_conf_path = joinpath(*ifenv('SystemRoot', |
||||||
|
lambda path: [path, 'System32', 'drivers', 'etc'], |
||||||
|
lambda: [sys_root_path, 'etc'])) |
||||||
|
|
||||||
|
# Get user home directory |
||||||
|
user_home_path = expanduser('~') |
||||||
|
|
||||||
|
# Get path of this Python file |
||||||
|
if '__file__' in globals(): |
||||||
|
script_path = dirname(abspath(__file__)) |
||||||
|
else: |
||||||
|
script_path = dirname(abspath(argv[0])) |
||||||
|
|
||||||
|
# Build list of configuration file pathnames to search |
||||||
|
config_paths = uniq([ |
||||||
|
getcwd(), |
||||||
|
user_home_path, |
||||||
|
joinpath(user_home_path, '.config'), |
||||||
|
joinpath(user_home_path, '.local', 'share'), |
||||||
|
sys_conf_path, |
||||||
|
script_path]) |
||||||
|
config_pathnames = list(joinpath(p, f) for p in config_paths for f in CONFIG_FILENAMES) |
||||||
|
|
||||||
|
# Attempt to find a config file |
||||||
|
config_pathname = find_file(config_pathnames) |
||||||
|
|
||||||
|
if config_pathname is None: |
||||||
|
logger.warning('Unable to find a config file at:') |
||||||
|
for pathname in config_pathnames: |
||||||
|
logger.warning(' ' + pathname) |
||||||
|
|
||||||
|
return _warn_load_default() |
||||||
|
|
||||||
|
with open(config_pathname, 'rt', encoding='utf-8') as f: |
||||||
|
config_source = f.read() |
||||||
|
|
||||||
|
# Create configuration file scopes |
||||||
|
|
||||||
|
global_scope = dict( |
||||||
|
CRITICAL = logging.CRITICAL, |
||||||
|
ERROR = logging.ERROR, |
||||||
|
WARNING = logging.WARNING, |
||||||
|
INFO = logging.INFO, |
||||||
|
DEBUG = logging.DEBUG, |
||||||
|
NOTSET = logging.NOTSET, |
||||||
|
|
||||||
|
crontab = CronTrigger.from_crontab, |
||||||
|
cron = CronTrigger.from_crontab, |
||||||
|
|
||||||
|
interval = IntervalTrigger, |
||||||
|
every = IntervalTrigger) |
||||||
|
|
||||||
|
local_scope = dict() |
||||||
|
|
||||||
|
# Execute configuration file |
||||||
|
exec(config_source, global_scope, local_scope) |
||||||
|
|
||||||
|
# Merge configuration with default configuration |
||||||
|
config = merge_configs(DEFAULT_CONFIG, local_scope) |
||||||
|
|
||||||
|
logger.debug('Loaded configuration') |
||||||
|
|
||||||
|
if config['log_level'] <= logging.DEBUG: |
||||||
|
logger.debug('') |
||||||
|
for line in pprint.pformat(config).split('\n'): |
||||||
|
logging.debug(config['tab_size'] * ' ' + line) |
||||||
|
logger.debug('') |
||||||
|
|
||||||
|
return config |
||||||
|
|
||||||
|
def _warn_load_default(): |
||||||
|
global DEFAULT_CONFIG |
||||||
|
|
||||||
|
logger.warning('') |
||||||
|
logger.warning('Waiting 10 seconds before loading default configuration...') |
||||||
|
logger.warning('') |
||||||
|
sleep(10) |
||||||
|
logger.warning('Loading default configuration') |
||||||
|
logger.warning('') |
||||||
|
return deepcopy(DEFAULT_CONFIG) |
||||||
|
|
||||||
|
def merge_configs(base, diff, path=[]): |
||||||
|
base = base if len(path) == 0 else deepcopy(base) |
||||||
|
for key, value in diff.items(): |
||||||
|
if not key in base: |
||||||
|
base[key] = value |
||||||
|
elif not isinstance(value, type(base[key])): |
||||||
|
logger.error('Cannot merge diff type %s with base %s type at %s.%s', |
||||||
|
type(value), type(base[key]), '.'.join(path), key) |
||||||
|
return None |
||||||
|
elif isinstance(value, dict): |
||||||
|
merged = merge_configs(base[key], value, path + [key]) |
||||||
|
if merged is None: |
||||||
|
return None |
||||||
|
base[key] = merged |
||||||
|
else: |
||||||
|
base[key] = value |
||||||
|
return base |
@ -0,0 +1,32 @@ |
|||||||
|
import logging |
||||||
|
|
||||||
|
# Bootstrap logging using a hardcoded configuration |
||||||
|
def bootstrap_logging(): |
||||||
|
global logger |
||||||
|
|
||||||
|
logging.basicConfig( |
||||||
|
level=logging.NOTSET, |
||||||
|
format='%(asctime)s %(name)s [%(levelname)-8s]: %(message)s', |
||||||
|
datefmt='%a, %d %b %Y, %H:%M:%S') |
||||||
|
|
||||||
|
logger = logging.getLogger('zasd') |
||||||
|
logger.setLevel(logging.NOTSET) |
||||||
|
|
||||||
|
logging.getLogger('apscheduler').setLevel(logging.NOTSET) |
||||||
|
|
||||||
|
# Configure logging using a loaded configuration |
||||||
|
def configure_logging(config): |
||||||
|
global logger |
||||||
|
|
||||||
|
level = config['log_level'] |
||||||
|
|
||||||
|
logging.basicConfig( |
||||||
|
format=config['log_format'], |
||||||
|
datefmt=config['log_date_format']) |
||||||
|
|
||||||
|
logger = logging.getLogger('zasd') |
||||||
|
logger.setLevel(level) |
||||||
|
logging.getLogger('asyncio').setLevel(logging.WARN) |
||||||
|
logging.getLogger('apscheduler').setLevel(logging.WARN) |
||||||
|
|
||||||
|
bootstrap_logging() |
@ -0,0 +1,25 @@ |
|||||||
|
from os import environ |
||||||
|
from os.path import isfile |
||||||
|
|
||||||
|
# Return unique items in iterable |
||||||
|
def uniq(iterable): |
||||||
|
items = set() |
||||||
|
for item in iterable: |
||||||
|
if item not in items: |
||||||
|
items.add(item) |
||||||
|
yield item |
||||||
|
|
||||||
|
# Ternary operator for environment variables |
||||||
|
def ifenv(name, true_func, false_func): |
||||||
|
return true_func(environ[name]) if name in environ else false_func() |
||||||
|
|
||||||
|
# Search for file in pathnames, return the first pathname |
||||||
|
# that exists as a file, or None if no files were found |
||||||
|
def find_file(pathnames): |
||||||
|
try: |
||||||
|
# Find the first pathname that exists as a file |
||||||
|
pathname = next(p for p in pathnames if isfile(p)) |
||||||
|
except: |
||||||
|
pathname = None |
||||||
|
finally: |
||||||
|
return pathname |
Loading…
Reference in new issue