38. Python as a better shell: automation and a systemd service
38.1 When a shell script should become Python
Bash is excellent for 10-line glue. Switch to Python when the script needs
data structures, error handling beyond set -e, JSON or HTTP, tests, or
when it is longer than a screen. A typical operations script:
#!/usr/bin/env python3
# labs/cleanup.py: delete old files under a directory, safely, with a dry run
import argparse
import logging
import sys
import time
from pathlib import Path
log = logging.getLogger("cleanup")
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser()
p.add_argument("root", type=Path)
p.add_argument("--days", type=float, default=14)
p.add_argument("--glob", default="*.log*")
p.add_argument("--apply", action="store_true", help="actually delete (default: dry run)")
a = p.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
root = a.root.resolve()
if root == Path("/") or len(root.parts) < 3:
log.error("refusing to operate on %s", root) # guard against catastrophic paths
return 2
cutoff = time.time() - a.days * 86400
freed = 0
for f in root.rglob(a.glob):
if f.is_symlink() or not f.is_file():
continue # never follow symlinks out of root
st = f.stat()
if st.st_mtime < cutoff:
freed += st.st_size
log.info("%s %s (%.1f MB)", "delete" if a.apply else "would delete", f, st.st_size / 2**20)
if a.apply:
f.unlink(missing_ok=True)
log.info("total %.1f MB %s", freed / 2**20, "freed" if a.apply else "reclaimable")
return 0
if __name__ == "__main__":
sys.exit(main())
The habits in that script are the point: dry run by default, refuse
obviously dangerous targets, do not follow symlinks (a symlink to /etc
inside a log directory is a classic privilege-escalation path for root
cron jobs), log what you do, and return meaningful exit codes.
38.2 Run it on a schedule with a systemd timer
# /etc/systemd/system/cleanup.service
[Unit]
Description=Delete old application logs
[Service]
Type=oneshot
User=appsvc
ExecStart=/opt/tools/.venv/bin/python /opt/tools/cleanup.py /var/log/myapp --days 14 --apply
# Sandboxing (OS Ch 67): the script can only write where it must.
ProtectSystem=strict
ReadWritePaths=/var/log/myapp
PrivateTmp=yes
NoNewPrivileges=yes
# /etc/systemd/system/cleanup.timer
[Timer]
OnCalendar=daily
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl enable --now cleanup.timer
systemctl list-timers cleanup.timer
journalctl -u cleanup.service --since today # stdout/stderr land here
Point ExecStart at the virtual environment's interpreter, not
/usr/bin/python3, so the service gets exactly the dependencies you
installed. OS Ch 67
explains each sandboxing option.