now actually using at daemon

This commit is contained in:
2022-01-17 15:37:37 +02:00
parent 76cc8a4304
commit 5d0972e826
3 changed files with 224 additions and 135 deletions

View File

@@ -1,4 +1,4 @@
__version__ = "20220109.1" __version__ = "2022.0"
def get_version(): def get_version():

View File

@@ -3,50 +3,40 @@ from datetime import datetime, timedelta
import argparse import argparse
import os import os
import shutil import shutil
import sqlite3
import sys import sys
import tempfile
import time
import subprocess
class AtDel: class AtDel:
def __init__(self): def __init__(self):
self.config_file = os.path.expanduser("~/.cache/atdel") self.script = sys.argv[0]
self.queue = "q"
self.due = None
self.spool_folder = "/var/spool/atjobs/"
self.parse_opts() self.parse_opts()
self.db_init()
if self.options.days is not None:
self.set_file_status()
if self.options.days is None and not self.options.delete:
self.db_list()
if self.options.delete: if self.options.delete:
self.del_due_files() self.remove_file()
return
def db_init(self): if self.options.remove:
self.remove_job()
return
with sqlite3.connect(self.config_file) as con: if self.due is not None:
con.execute( self.add_job()
"""
CREATE TABLE IF NOT EXISTS atdel ( if self.due is None and not self.options.delete:
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, self.list_jobs()
name TEXT NOT NULL UNIQUE,
inode INTEGER NOT NULL,
added TEXT NOT NULL,
due TEXT NOT NULL
);
"""
)
con.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_name ON atdel (name);
"""
)
def parse_opts(self): def parse_opts(self):
"""Options parser""" """Options parser"""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Automatically delete files after due date. Note: file must have same inode to be deleted", description="Automatically delete files after due date. Note: file must have same inode to be deleted",
epilog="Automate deletion by adding cron '0 0 * * * atdel --delete'", epilog="",
) )
parser.add_argument( parser.add_argument(
"--verbose", "--verbose",
@@ -56,21 +46,47 @@ class AtDel:
default=False, default=False,
help="Increase verbosity", help="Increase verbosity",
) )
parser.add_argument( parser.add_argument(
"--delete", "-D",
action="store_true", action="store",
dest="delete", dest="remove",
default=False, default=None,
help="Delete all due files", type=int,
help="Remove deletion job ID",
) )
parser.add_argument( parser.add_argument(
"-d", "-d",
action="store", action="store",
type=int, type=float,
help="Days to keep files. 0 to remove deletion tag", help="Days to keep files.",
default=None, default=None,
dest="days", dest="days",
) )
parser.add_argument(
"-t",
action="store",
type=str,
help="Timespec when to delete, using date -d command. Example '02/01 15:00'",
default=None,
dest="time",
)
parser.add_argument(
"--delete-file",
action="store",
dest="delete",
default=None,
type=str,
help="Delete file with ID. Normally this is only called from at job.",
)
parser.add_argument(
"--inode",
action="store",
dest="inode",
default=None,
type=int,
help="Inode of file stored in jobspec. Normally this is only called from at job.",
)
parser.add_argument( parser.add_argument(
"files", "files",
action="store", action="store",
@@ -80,104 +96,170 @@ class AtDel:
nargs="*", nargs="*",
) )
self.options = parser.parse_args() self.options = parser.parse_args()
if self.options.days is None and len(self.options.files) > 0: if (self.options.days is None and self.options.time is None) and len(
parser.error("If files set, must give -d for retain length") self.options.files
) > 0:
parser.error("If files set, must give -d or -t")
def set_file_status(self): if self.options.days and self.options.time:
parser.error("Only -t OR -d")
to_remove = self.options.days == 0 if self.options.days:
now = datetime.now() self.due = datetime.now().replace(microsecond=0) + timedelta(days=self.options.days)
del_delta = timedelta(days=self.options.days)
del_time = (now + del_delta).isoformat() if self.options.time:
now_time = now.isoformat() self.due = datetime.fromtimestamp(
int(
subprocess.run(
["date", "-d", self.options.time, "+%s"],
capture_output=True,
).stdout
)
)
if self.options.delete:
if self.options.inode is None:
parser.error("--inode is required with --delete-file")
def add_job(self):
now = datetime.now().replace(microsecond=0)
diff = self.due - now
script = """
#ADDED {added}
#DELETE {due}
#INODE {inode}
#FILE {path}
{script} --inode {inode} --delete-file '{path_quoted}'
"""
print("To be deleted in {} days, or on {}".format(diff.days, self.due))
with sqlite3.connect(self.config_file) as con:
for f in self.options.files: for f in self.options.files:
path = os.path.abspath(f) path = os.path.abspath(f)
inode = os.stat(f).st_ino inode = os.stat(f).st_ino
if to_remove: commands = script.format(
rows = con.execute("SELECT name FROM atdel WHERE name = ?", (path,)) added=now.isoformat(),
if len(rows.fetchall()) == 0: due=self.due.isoformat(),
print("No such file in database: '{}'".format(path)) inode=inode,
path=path,
script=self.script,
path_quoted=path.replace("'", "\\'"),
)
with tempfile.NamedTemporaryFile(delete=False) as fp:
fp.write(commands.encode("utf-8"))
fp.close()
p = subprocess.run(
[
"at",
"-q",
self.queue,
"-f",
fp.name,
"-t",
self.due.strftime("%Y%m%d%H%M"),
],
capture_output=True
)
for row in p.stderr.decode('utf-8').split("\n"):
if row.startswith("job "):
print(" ".join(row.split(" ")[0:2]))
os.remove(fp.name)
def list_jobs(self):
p = subprocess.run(["atq", "-q", self.queue], capture_output=True)
jobs = []
for row in p.stdout.decode("utf-8").split("\n"):
try:
id = int(row.split("\t")[0],10)
jobs.append(self.parse_job(id))
except Exception as e:
# ~ print(e)
continue
jobs.sort(key=lambda x: x['due'])
if self.options.verbose:
print("{:4s} {:14s} {:14s} {:4s} {}".format("ID", "Added", "Due", "Days", "File"))
for job in jobs:
job['added_str'] = job['added'].strftime("%y-%m-%d %H:%M")
job['due_str'] = job['due'].strftime("%y-%m-%d %H:%M")
print("{id:4d} {added_str} {due_str} {days:4.0f} {path}".format(
**job
))
else: else:
con.execute( print("{:4s} {:4s} {}".format("ID", "Days", "File"))
"DELETE FROM atdel WHERE name = ?;", for job in jobs:
(path,), print("{id:4d} {days:4.0f} {path}".format(
) **job
print("Removed: {}".format(path)) ))
else:
con.execute(
"INSERT OR REPLACE INTO atdel (name, due, added, inode) values(?, ?, ?, ?);",
(path, del_time, now_time, inode),
)
print(f)
if not to_remove:
print(
"To be deleted in {} days, or on {}".format(self.options.days, del_time)
)
def db_list(self): def parse_job(self, id):
data = [] p = subprocess.run(["at", "-c", str(id)], capture_output=True)
with sqlite3.connect(self.config_file) as con: jobspec = {
rows = con.execute("SELECT added, due, name FROM atdel ORDER BY added;") "id": id,
for row in rows: "path": None,
due = (datetime.fromisoformat(row[1]) - datetime.now()).days "added": None,
rel = os.path.relpath(row[2]) "due": None,
if rel.startswith(".."): "inode": None,
rel = row[2] "days": None
data.append([row[0][0:10], row[1][0:10], due, rel]) }
print("{:10s} {:10s} {:4s} {}".format("Added", "Due", "Days", "File"))
for row in data:
print("{:10s} {:10s} {:4d} {}".format(*row))
def del_due_files(self): for row in p.stdout.decode("utf-8").split("\n"):
try:
if row.startswith("#FILE "):
jobspec["path"] = row.strip()[6:]
if row.startswith("#ADDED "):
jobspec["added"] = datetime.fromisoformat(row.strip()[7:27])
if row.startswith("#DELETE "):
jobspec["due"] = datetime.fromisoformat(row.strip()[8:28])
jobspec["days"] = round((jobspec["due"] - datetime.now()).total_seconds()/86400,2)
if row.startswith("#INODE "):
jobspec["inode"] = int(row.strip()[7:])
except Exception as e:
# ~ print("Error parsing ID: {} - {}".format(id, str(e)))
raise e
return jobspec
def remove_file(self):
"""Delete files where due date has passed""" """Delete files where due date has passed"""
paths = [] inode = self.options.inode
with sqlite3.connect(self.config_file) as con: path = self.options.delete
rows = con.execute(
"SELECT added, due, name, inode FROM atdel ORDER BY added;"
)
for row in rows:
due = (datetime.fromisoformat(row[1]) - datetime.now()).days
exists = os.path.exists(row[2])
if due < 0 or not exists:
paths.append([row[2], row[3]])
for (p, inode) in paths:
try: try:
if not os.path.exists(p): if not os.path.exists(path):
print("File {} doesnt exist, removing from DB".format(p)) print("File {} doesnt exist.".format(path),
file=sys.stderr
)
else: else:
curr_inode = os.stat(p).st_ino curr_inode = os.stat(path).st_ino
if curr_inode != inode: if curr_inode != inode:
print( print(
"Path has different inode, possible security issue: {}".format( "Path has different inode, possible security issue: {}".format(
p path
), ),
file=sys.stderr, file=sys.stderr,
) )
continue return
if os.path.isdir(p): if os.path.isdir(path):
print("Deleting folder {}".format(p)) print("Deleting folder {}".format(path))
shutil.rmtree(p) shutil.rmtree(path)
else: else:
print("Deleting file {}".format(p)) print("Deleting file {}".format(path))
os.remove(p) os.remove(path)
self.db_remove(p)
except Exception as e: except Exception as e:
print(e, file=sys.stderr) print(e, file=sys.stderr)
def db_remove(self, path): def remove_job(self):
with sqlite3.connect(self.config_file) as con:
con.execute( p = subprocess.run(
""" ["atrm", str(self.options.remove)],
DELETE FROM atdel WHERE name = ?;
""",
(path,),
) )

33
test.sh
View File

@@ -1,20 +1,27 @@
#!/bin/bash #!/bin/bash
set -x set -x -e
pipx install -f .
touch foo touch foo
stat foo stat foo
atdel -d -2 foo atdel -d 2 foo
atdel -t "02/01 15:00" foo
rm foo
touch bar foo fuu
mv fuu foo
rm bar
stat foo
atdel atdel
atdel --delete atdel -v
lastid=$( atdel | tail -n 1 | awk '{ print $1 }' )
echo $lastid
atdel -D $lastid
inode=$( stat -c %i foo )
atdel --delete-file $( readlink -f foo ) || true
atdel --inode 1 --delete-file $( readlink -f foo )
atdel --inode $inode --delete-file $( readlink -f foo )
mkdir -p bar
touch bar/foo
atdel -d 2 bar
atdel
inode=$( stat -c %i bar )
atdel --inode $inode --delete-file $( readlink -f bar )
atdel -d -2 foo
atdel --delete
atdel -d -2 nonexist