mirror of
https://github.com/MSWS/Homelab.git
synced 2025-12-05 23:00:24 -08:00
Improve backup script
This commit is contained in:
@@ -12,9 +12,9 @@ exec > >(logger -t backup) 2>&1
|
||||
#/usr/bin/restic unlock
|
||||
#/usr/bin/restic backup -o s3.storage-class=INTELLIGENT_TIERING --exclude-file=/home/iboaz/excludes.txt --one-file-system --verbose /
|
||||
/usr/bin/restic -r /mnt/usb/backups/ unlock
|
||||
/usr/bin/restic -r /mnt/usb/backups/ backup --exclude-file=/home/iboaz/excludes.txt --one-file-system --verbose /
|
||||
/usr/bin/restic -r /mnt/usb/backups/ backup --exclude-file=/home/iboaz/excludes.txt --one-file-system --verbose --json / | python3 /home/iboaz/backup_log.py "USB"
|
||||
/usr/bin/restic -r b2:debian-homelab-backups:/ unlock
|
||||
/usr/bin/restic -r b2:debian-homelab-backups:/ backup --exclude-file=/home/iboaz/excludes.txt --one-file-system --verbose /
|
||||
/usr/bin/restic -r b2:debian-homelab-backups:/ backup --exclude-file=/home/iboaz/excludes.txt --one-file-system --verbose / | python3 /home/iboaz/backup_log.py "Backblaze"
|
||||
/usr/bin/restic forget --keep-last 1 --keep-daily 3 --keep-weekly 4 --keep-monthly 3 --keep-yearly 3 --prune
|
||||
/usr/bin/restic -r /mnt/usb/backups/ forget --keep-last 1 --keep-daily 7 --keep-weekly 8 --keep-monthly 3 --keep-yearly 3 --prune
|
||||
/usr/bin/restic -r b2:debian-homelab-backups:/ forget --keep-last 1 --keep-daily 3 --keep-weekly 4 --keep-monthly 3 --keep-yearly 3 --prune
|
||||
|
||||
191
backup_log.py
191
backup_log.py
@@ -1,23 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
backup_to_discord_two_messages_full.py
|
||||
backup_to_discord_two_messages_dest.py
|
||||
|
||||
- Posts two messages to a Discord webhook:
|
||||
1) A static "Backup started" embed (no initial progress field).
|
||||
2) A separate "in-progress" embed that is edited (rate-limited) and finally edited to a
|
||||
"Backup finished" embed that contains duration, size delta, and related fields.
|
||||
|
||||
- Progress percent appears in the in-progress embed title.
|
||||
- ETA is estimated from observed throughput (bytes_done / elapsed).
|
||||
- Sensible fields are inlined.
|
||||
- In-progress updates are sent at most once every MIN_UPDATE_INTERVAL seconds (default 5).
|
||||
- Final "Backup finished" embed contains no percent in the title and no human-written timestamp
|
||||
in the description. The embed still contains Discord's native timestamp field.
|
||||
|
||||
Usage:
|
||||
backup_program [args] | python3 backup_to_discord_two_messages_full.py https://discord.com/api/webhooks/ID/TOKEN
|
||||
or
|
||||
DISCORD_WEBHOOK="https://discord.com/api/webhooks/ID/TOKEN" backup_program | python3 backup_to_discord_two_messages_full.py
|
||||
Behavior:
|
||||
Expects a single command line argument: the Destination string describing where the backup is being sent.
|
||||
Reads newline-delimited JSON status lines from stdin.
|
||||
Uses DISCORD_WEBHOOK environment variable for the webhook URL.
|
||||
Posts two Discord messages:
|
||||
1) A static "Backup started" embed.
|
||||
2) An editable "in progress" embed with percent in the title, transferred, files, elapsed, ETA.
|
||||
Edited at most once every MIN_UPDATE_INTERVAL seconds.
|
||||
Finally edited to a "Backup finished" embed with duration and size delta.
|
||||
ETA is estimated from bytes_done / elapsed.
|
||||
All embeds depend only on native Discord timestamps.
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -28,19 +23,17 @@ import datetime
|
||||
import requests
|
||||
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
|
||||
|
||||
# Configuration
|
||||
WEBHOOK_ENV = "DISCORD_WEBHOOK"
|
||||
WEBHOOK_URL = None
|
||||
USERNAME = "Backup Bot"
|
||||
AVATAR_URL = None
|
||||
UPDATE_HEADERS = {"Content-Type": "application/json"}
|
||||
MIN_UPDATE_INTERVAL = 5.0 # seconds between in-progress edits
|
||||
MIN_UPDATE_INTERVAL = 5.0
|
||||
|
||||
def now_iso_z():
|
||||
"""Return ISO 8601 UTC timestamp with Z for Discord embed timestamp."""
|
||||
return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||
return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
def human_bytes(n):
|
||||
"""Human-readable bytes. Accepts numeric or numeric-strings."""
|
||||
try:
|
||||
n = float(n)
|
||||
except Exception:
|
||||
@@ -52,7 +45,6 @@ def human_bytes(n):
|
||||
return f"{n:.2f} EiB"
|
||||
|
||||
def nice_hms(seconds):
|
||||
"""Return a nice H:MM:SS for a number of seconds, or 'unknown'."""
|
||||
try:
|
||||
if seconds is None:
|
||||
return "unknown"
|
||||
@@ -62,21 +54,15 @@ def nice_hms(seconds):
|
||||
return "unknown"
|
||||
|
||||
def _add_wait_param(url):
|
||||
"""Append ?wait=true to the webhook URL so Discord returns the created message object."""
|
||||
p = urlparse(url)
|
||||
qs = parse_qs(p.query)
|
||||
qs["wait"] = ["true"]
|
||||
new_q = urlencode(qs, doseq=True)
|
||||
return urlunparse((p.scheme, p.netloc, p.path, p.params, new_q, p.fragment))
|
||||
|
||||
def post_message(webhook_url, payload, wait_for_message=False):
|
||||
"""
|
||||
POST a webhook. If wait_for_message True, append ?wait=true so Discord returns the message object.
|
||||
Returns the JSON message object on success (when available), otherwise None.
|
||||
Raises on non-success status codes.
|
||||
"""
|
||||
url = _add_wait_param(webhook_url) if wait_for_message else webhook_url
|
||||
resp = requests.post(url, json=payload, headers=UPDATE_HEADERS, timeout=15)
|
||||
def post_message(url, payload, wait_for_message=False):
|
||||
used_url = _add_wait_param(url) if wait_for_message else url
|
||||
resp = requests.post(used_url, json=payload, headers=UPDATE_HEADERS, timeout=15)
|
||||
if resp.status_code in (200, 201):
|
||||
try:
|
||||
return resp.json()
|
||||
@@ -87,12 +73,8 @@ def post_message(webhook_url, payload, wait_for_message=False):
|
||||
resp.raise_for_status()
|
||||
return None
|
||||
|
||||
def edit_message(webhook_url, message_id, payload):
|
||||
"""
|
||||
PATCH /webhooks/{id}/{token}/messages/{message_id}
|
||||
Returns the JSON message object on success when available, otherwise None.
|
||||
"""
|
||||
parsed = urlparse(webhook_url)
|
||||
def edit_message(url, message_id, payload):
|
||||
parsed = urlparse(url)
|
||||
base = f"{parsed.scheme}://{parsed.netloc}"
|
||||
path = parsed.path.rstrip("/")
|
||||
edit_url = f"{base}{path}/messages/{message_id}"
|
||||
@@ -108,11 +90,6 @@ def edit_message(webhook_url, message_id, payload):
|
||||
return None
|
||||
|
||||
def make_embed(title=None, description=None, fields=None, color=0x00aaff, timestamp=None):
|
||||
"""
|
||||
Create an embed dict.
|
||||
fields: list of tuples (name, value, inline_bool)
|
||||
timestamp: ISO 8601 string with Z, or None to use current time
|
||||
"""
|
||||
e = {}
|
||||
if title is not None:
|
||||
e["title"] = title
|
||||
@@ -120,15 +97,11 @@ def make_embed(title=None, description=None, fields=None, color=0x00aaff, timest
|
||||
e["description"] = description
|
||||
e["color"] = color
|
||||
if fields:
|
||||
e["fields"] = [{"name": name, "value": value, "inline": bool(inline)} for (name, value, inline) in fields]
|
||||
e["timestamp"] = (timestamp or now_iso_z())
|
||||
e["fields"] = [{"name": n, "value": v, "inline": bool(i)} for (n, v, i) in fields]
|
||||
e["timestamp"] = timestamp or now_iso_z()
|
||||
return e
|
||||
|
||||
def compute_eta(bytes_done, total_bytes, elapsed_seconds):
|
||||
"""
|
||||
Compute ETA in seconds: remaining / current_rate.
|
||||
Returns None if computation is not possible.
|
||||
"""
|
||||
try:
|
||||
if total_bytes is None:
|
||||
return None
|
||||
@@ -140,20 +113,24 @@ def compute_eta(bytes_done, total_bytes, elapsed_seconds):
|
||||
if rate <= 0:
|
||||
return None
|
||||
remaining = max(0.0, total_bytes - bytes_done)
|
||||
eta = remaining / rate
|
||||
return eta
|
||||
return remaining / rate
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def usage_and_exit():
|
||||
sys.stderr.write("Usage: python3 backup_to_discord_two_messages_dest.py \"Destination\"\n")
|
||||
sys.exit(2)
|
||||
|
||||
def main():
|
||||
global WEBHOOK_URL
|
||||
if len(sys.argv) > 1:
|
||||
WEBHOOK_URL = sys.argv[1].strip()
|
||||
else:
|
||||
WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK")
|
||||
|
||||
if len(sys.argv) != 2:
|
||||
usage_and_exit()
|
||||
destination = sys.argv[1]
|
||||
|
||||
WEBHOOK_URL = os.environ.get(WEBHOOK_ENV)
|
||||
if not WEBHOOK_URL:
|
||||
print("ERROR: Discord webhook URL must be provided as first arg or DISCORD_WEBHOOK env var", file=sys.stderr)
|
||||
sys.stderr.write(f"ERROR: {WEBHOOK_ENV} must be set\n")
|
||||
sys.exit(2)
|
||||
|
||||
started = False
|
||||
@@ -174,7 +151,6 @@ def main():
|
||||
|
||||
mtype = obj.get("message_type") or obj.get("type") or "status"
|
||||
|
||||
# On first status: post the static "Backup started" embed and create the in-progress embed.
|
||||
if not started and mtype == "status":
|
||||
started = True
|
||||
start_time_epoch = time.time()
|
||||
@@ -184,30 +160,32 @@ def main():
|
||||
total_bytes = obj.get("total_bytes")
|
||||
total_files = obj.get("total_files", "?")
|
||||
|
||||
# Static "Backup started" embed (no initial progress field)
|
||||
start_title = "Backup started"
|
||||
start_fields = [
|
||||
("Destination", destination, True),
|
||||
("Transferred", f"{human_bytes(bytes_done)}" + (f" / {human_bytes(total_bytes)}" if total_bytes else ""), True),
|
||||
("Files total", str(total_files), True),
|
||||
]
|
||||
start_payload = {
|
||||
"username": USERNAME,
|
||||
"avatar_url": AVATAR_URL,
|
||||
"embeds": [ make_embed(title=start_title, description=f"Started at {now_iso_z()}", fields=start_fields, color=0x3498db) ]
|
||||
"embeds": [
|
||||
make_embed(
|
||||
title="Backup Started",
|
||||
description=None,
|
||||
fields=start_fields,
|
||||
color=0x3498db
|
||||
)
|
||||
]
|
||||
}
|
||||
try:
|
||||
resp = post_message(WEBHOOK_URL, start_payload, wait_for_message=True)
|
||||
if resp and isinstance(resp, dict) and resp.get("id"):
|
||||
started_message_id = resp.get("id")
|
||||
sys.stderr.write(f"Posted started message id={started_message_id}\n")
|
||||
else:
|
||||
sys.stderr.write("Posted started message but no id returned\n")
|
||||
started_message_id = resp.get("id") if resp and isinstance(resp, dict) else None
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Failed to POST started message: {e}\n")
|
||||
|
||||
# Create the in-progress message and capture its id
|
||||
in_title = f"Backup in progress ({percent*100:5.2f}%)"
|
||||
in_title = f"Backup in progress ({percent * 100:5.2f}%)"
|
||||
in_fields = [
|
||||
("Destination", destination, True),
|
||||
("Transferred", f"{human_bytes(bytes_done)}" + (f" / {human_bytes(total_bytes)}" if total_bytes else ""), True),
|
||||
("Files", str(total_files), True),
|
||||
("Elapsed", "0:00:00", True),
|
||||
@@ -216,22 +194,24 @@ def main():
|
||||
in_payload = {
|
||||
"username": USERNAME,
|
||||
"avatar_url": AVATAR_URL,
|
||||
"embeds": [ make_embed(title=in_title, description="Progress updates will follow", fields=in_fields, color=0xf1c40f) ]
|
||||
"embeds": [
|
||||
make_embed(
|
||||
title=in_title,
|
||||
description=None,
|
||||
fields=in_fields,
|
||||
color=0xf1c40f
|
||||
)
|
||||
]
|
||||
}
|
||||
try:
|
||||
resp = post_message(WEBHOOK_URL, in_payload, wait_for_message=True)
|
||||
if resp and isinstance(resp, dict) and resp.get("id"):
|
||||
inprogress_message_id = resp.get("id")
|
||||
sys.stderr.write(f"Created in-progress message id={inprogress_message_id}\n")
|
||||
else:
|
||||
sys.stderr.write("Created in-progress message but no id returned\n")
|
||||
inprogress_message_id = resp.get("id") if resp and isinstance(resp, dict) else None
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Failed to create in-progress message: {e}\n")
|
||||
sys.stderr.write(f"Failed to create in progress message: {e}\n")
|
||||
|
||||
last_update_time = time.time()
|
||||
continue
|
||||
|
||||
# Status updates: edit the in-progress message at most once every MIN_UPDATE_INTERVAL seconds
|
||||
if mtype == "status":
|
||||
if not started:
|
||||
continue
|
||||
@@ -246,45 +226,50 @@ def main():
|
||||
files_done = obj.get("files_done")
|
||||
files_total = obj.get("total_files", "?")
|
||||
|
||||
elapsed_s = now - (start_time_epoch or now)
|
||||
elapsed_s = now - start_time_epoch if start_time_epoch else 0
|
||||
eta_s = compute_eta(bytes_done, total_bytes, elapsed_s)
|
||||
eta_str = nice_hms(eta_s) if eta_s is not None else "unknown"
|
||||
|
||||
title = f"Backup in progress ({percent*100:5.2f}%)"
|
||||
title = f"Backup in progress ({percent * 100:5.2f}%)"
|
||||
elapsed_hms = nice_hms(elapsed_s)
|
||||
fields = [
|
||||
("Destination", destination, True),
|
||||
("Transferred", f"{human_bytes(bytes_done)}" + (f" / {human_bytes(total_bytes)}" if total_bytes else ""), True),
|
||||
("Files processed", f"{files_done or '?'} / {files_total}", True),
|
||||
("Elapsed", elapsed_hms, True),
|
||||
("ETA", eta_str, True),
|
||||
]
|
||||
payload = {
|
||||
"embeds": [ make_embed(title=title, description=f"Updated {now_iso_z()}", fields=fields, color=0xf1c40f, timestamp=now_iso_z()) ]
|
||||
"embeds": [
|
||||
make_embed(
|
||||
title=title,
|
||||
description=None,
|
||||
fields=fields,
|
||||
color=0xf1c40f,
|
||||
timestamp=now_iso_z()
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
if inprogress_message_id:
|
||||
try:
|
||||
edit_message(WEBHOOK_URL, inprogress_message_id, payload)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Failed to edit in-progress message id={inprogress_message_id}: {e}\n")
|
||||
sys.stderr.write(f"Failed to edit in progress message: {e}\n")
|
||||
else:
|
||||
try:
|
||||
resp = post_message(WEBHOOK_URL, payload, wait_for_message=True)
|
||||
if resp and isinstance(resp, dict) and resp.get("id"):
|
||||
inprogress_message_id = resp.get("id")
|
||||
sys.stderr.write(f"Created in-progress message id={inprogress_message_id}\n")
|
||||
else:
|
||||
sys.stderr.write("Created in-progress message but no id returned\n")
|
||||
inprogress_message_id = resp.get("id") if resp and isinstance(resp, dict) else None
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Failed to POST in-progress message fallback: {e}\n")
|
||||
sys.stderr.write(f"Failed to POST in progress fallback: {e}\n")
|
||||
|
||||
last_update_time = time.time()
|
||||
continue
|
||||
|
||||
# Final summary: edit the in-progress message to finished
|
||||
if mtype == "summary":
|
||||
obj_s = obj
|
||||
total_duration = obj_s.get("total_duration")
|
||||
|
||||
if total_duration is None:
|
||||
bs = obj_s.get("backup_start")
|
||||
be = obj_s.get("backup_end")
|
||||
@@ -295,6 +280,7 @@ def main():
|
||||
total_duration = (t_be - t_bs).total_seconds()
|
||||
except Exception:
|
||||
total_duration = None
|
||||
|
||||
if total_duration is None and start_time_epoch:
|
||||
total_duration = time.time() - start_time_epoch
|
||||
|
||||
@@ -302,50 +288,55 @@ def main():
|
||||
files_changed = obj_s.get("files_changed", 0)
|
||||
files_new = obj_s.get("files_new", 0)
|
||||
files_unmodified = obj_s.get("files_unmodified", 0)
|
||||
total_files_processed = obj_s.get("total_files_processed", obj_s.get("total_files_processed") or "?")
|
||||
total_files_processed = obj_s.get("total_files_processed", "?")
|
||||
|
||||
duration_str = "?"
|
||||
if isinstance(total_duration, (int, float)):
|
||||
if total_duration >= 1:
|
||||
duration_str = nice_hms(total_duration)
|
||||
else:
|
||||
duration_str = f"{total_duration:.3f} seconds"
|
||||
else:
|
||||
duration_str = "?"
|
||||
|
||||
size_str = human_bytes(int(size_delta)) if isinstance(size_delta, (int, float)) or str(size_delta).isdigit() else str(size_delta)
|
||||
|
||||
# No percent in the finished title, and no human-written timestamp in description
|
||||
title = "Backup finished"
|
||||
description = None
|
||||
size_str = human_bytes(size_delta) if isinstance(size_delta, (int, float)) or str(size_delta).isdigit() else str(size_delta)
|
||||
|
||||
fields = [
|
||||
("Destination", destination, True),
|
||||
("Duration", duration_str, True),
|
||||
("Backup size delta", size_str, True),
|
||||
("Files changed/new/unmodified", f"{files_changed} / {files_new} / {files_unmodified}", False),
|
||||
("Total files processed", str(total_files_processed), True),
|
||||
]
|
||||
payload = {
|
||||
"embeds": [ make_embed(title=title, description=description, fields=fields, color=0x2ecc71, timestamp=now_iso_z()) ]
|
||||
"embeds": [
|
||||
make_embed(
|
||||
title="Backup Finished",
|
||||
description=None,
|
||||
fields=fields,
|
||||
color=0x2ecc71,
|
||||
timestamp=now_iso_z()
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
if inprogress_message_id:
|
||||
try:
|
||||
edit_message(WEBHOOK_URL, inprogress_message_id, payload)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Failed to edit final in-progress message id={inprogress_message_id}: {e}\nPosting final message as fallback\n")
|
||||
sys.stderr.write(f"Failed to edit final message: {e}\nPosting fallback\n")
|
||||
try:
|
||||
post_message(WEBHOOK_URL, {"username": USERNAME, "avatar_url": AVATAR_URL, "embeds": payload["embeds"]}, wait_for_message=False)
|
||||
post_message(WEBHOOK_URL, {"username": USERNAME, "avatar_url": AVATAR_URL, "embeds": payload["embeds"]})
|
||||
except Exception as e2:
|
||||
sys.stderr.write(f"Failed to POST final fallback message: {e2}\n")
|
||||
sys.stderr.write(f"Fallback failed: {e2}\n")
|
||||
else:
|
||||
try:
|
||||
post_message(WEBHOOK_URL, {"username": USERNAME, "avatar_url": AVATAR_URL, "embeds": payload["embeds"]}, wait_for_message=False)
|
||||
post_message(WEBHOOK_URL, {"username": USERNAME, "avatar_url": AVATAR_URL, "embeds": payload["embeds"]})
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Failed to POST final summary message: {e}\n")
|
||||
sys.stderr.write(f"Posting summary failed: {e}\n")
|
||||
|
||||
last_update_time = time.time()
|
||||
continue
|
||||
|
||||
# finished reading stdin
|
||||
return
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user