Files
Panama/config/dot/quickshell/scripts/panama-snapshots
T

396 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""Snapshots, through snapper.
A point in time you can go back to, per btrfs subvolume. This machine already
had snapper running hourly, but only for / -- /home is a separate subvolume and
had no configuration at all, so six hundred snapshots existed and not one of
them contained a document.
Deliberately absent: rollback. snapper's rollback works by changing the btrfs
default subvolume, and this system's fstab pins subvol=root and subvol=home
explicitly, which overrides it -- so a rollback would appear to succeed and
change nothing after a reboot. Restoring files and folders out of a snapshot
needs no reboot, cannot affect booting, and covers the cases people actually
hit.
panama-snapshots snapshot
panama-snapshots create CONFIG DESCRIPTION
panama-snapshots delete CONFIG NUMBER
panama-snapshots set-retention CONFIG HOURLY DAILY WEEKLY
panama-snapshots set-timeline CONFIG true|false
panama-snapshots browse CONFIG NUMBER [RELATIVE_PATH]
panama-snapshots restore CONFIG NUMBER RELATIVE_PATH
"""
from __future__ import annotations
import csv
import io
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
CONFIG_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
# Directory listings are for choosing something to restore, not for browsing a
# terabyte. A folder with more entries than this is reported as truncated.
BROWSE_LIMIT = 400
class BoundaryError(RuntimeError):
"""A user-visible validation or snapper failure."""
def run(command: list[str], timeout: float = 30.0) -> subprocess.CompletedProcess:
try:
return subprocess.run(command, capture_output=True, text=True,
timeout=timeout, check=False)
except subprocess.TimeoutExpired as error:
raise BoundaryError("snapper did not answer in time.") from error
except OSError as error:
raise BoundaryError("snapper is not available.") from error
def require_config(name: str) -> str:
if not CONFIG_NAME.fullmatch(name or ""):
raise BoundaryError("That is not a snapshot configuration.")
return name
def require_number(value: str) -> int:
if not str(value).isdigit():
raise BoundaryError("That is not a snapshot.")
number = int(value)
# 0 is the live filesystem, not a snapshot, and must never be a target.
if number < 1:
raise BoundaryError("That is the current state, not a snapshot.")
return number
# Everything snapper answers is read through --machine-readable csv and a
# DictReader. The first version split snapper's box-drawing table by column
# INDEX, which is the presentation layer: it is localized, and its columns
# have moved between snapper versions -- a German desktop parsed to zero
# snapshots while `snapper list` showed twelve. Named columns survive both.
def snapper_csv(args: list[str], timeout: int = 20) -> list[dict]:
result = run(["snapper", "--machine-readable", "csv", *args], timeout=timeout)
if result.returncode != 0:
return []
return list(csv.DictReader(io.StringIO(result.stdout)))
def config_names() -> list[str]:
return [row["config"] for row in snapper_csv(["list-configs"])
if CONFIG_NAME.fullmatch(row.get("config", ""))]
def config_settings(name: str) -> dict:
return {row["key"]: row.get("value", "")
for row in snapper_csv(["-c", name, "get-config"]) if row.get("key")}
def snapshots_for(name: str) -> list[dict]:
entries = []
for row in snapper_csv(["-c", name, "list"], timeout=45):
if not str(row.get("number", "")).isdigit():
continue
number = int(row["number"])
if number == 0:
continue
cleanup = row.get("cleanup", "") or ""
entries.append({
"number": number,
"kind": row.get("type", ""),
"date": row.get("date", ""),
"user": row.get("user", ""),
"cleanup": cleanup,
"description": row.get("description", ""),
# A snapshot with no cleanup algorithm is not on the timeline's
# list to remove, which is what "kept" means to someone reading it.
"kept": cleanup == "",
})
entries.sort(key=lambda entry: entry["number"], reverse=True)
return entries
def btrfs_subvolumes() -> list[dict]:
"""Mounted btrfs subvolumes, so the page can name what is NOT protected."""
result = run(["findmnt", "-t", "btrfs", "-J", "-o", "TARGET,OPTIONS"])
if result.returncode != 0:
return []
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError:
return []
found = []
def walk(nodes):
for node in nodes:
options = str(node.get("options", ""))
target = str(node.get("target", ""))
match = re.search(r"subvol=(/[^,]*)", options)
# .snapshots holds the snapshots themselves and is not a thing to
# protect; listing it would offer to snapshot the snapshots.
if match and "/.snapshots" not in target:
found.append({"path": target, "subvolume": match.group(1)})
walk(node.get("children", []))
walk(payload.get("filesystems", []))
return found
def free_space() -> dict:
try:
usage = shutil.disk_usage("/home")
except OSError:
return {"freeBytes": 0, "totalBytes": 0}
return {"freeBytes": usage.free, "totalBytes": usage.total}
def timeline_running() -> bool:
return run(["systemctl", "is-active", "snapper-timeline.timer"]).stdout.strip() == "active"
def snapshot() -> dict:
configs = []
protected_paths = set()
for name in config_names():
settings = config_settings(name)
subvolume = settings.get("SUBVOLUME", "")
protected_paths.add(subvolume)
configs.append({
"name": name,
"subvolume": subvolume,
"timelineEnabled": settings.get("TIMELINE_CREATE", "no") == "yes",
# Empty when this user cannot read the config at all, which is a
# different state from "no snapshots".
"readable": bool(settings),
"limits": {
"hourly": int(settings.get("TIMELINE_LIMIT_HOURLY") or 0),
"daily": int(settings.get("TIMELINE_LIMIT_DAILY") or 0),
"weekly": int(settings.get("TIMELINE_LIMIT_WEEKLY") or 0),
"monthly": int(settings.get("TIMELINE_LIMIT_MONTHLY") or 0),
"yearly": int(settings.get("TIMELINE_LIMIT_YEARLY") or 0),
},
"snapshots": snapshots_for(name) if settings else [],
})
configs.sort(key=lambda entry: entry["subvolume"])
unprotected = [entry for entry in btrfs_subvolumes()
if entry["path"] not in protected_paths]
return {
"configs": configs,
"unprotected": unprotected,
"timelineRunning": timeline_running(),
"space": free_space(),
# An ext4 machine and an unconfigured btrfs machine used to look
# identical: an empty page. "supported" is whether snapshots are even
# possible here, so absence can say which kind of absence it is.
"supported": bool(btrfs_subvolumes()),
"error": "",
}
def snapshot_root(config: str, number: int) -> Path:
settings = config_settings(config)
subvolume = settings.get("SUBVOLUME", "")
if not subvolume:
raise BoundaryError("That snapshot configuration cannot be read.")
path = Path(subvolume) / ".snapshots" / str(number) / "snapshot"
if not path.is_dir():
raise BoundaryError("That snapshot is not available.")
return path
def safe_relative(root: Path, relative: str) -> Path:
"""Resolve a path inside a snapshot, refusing anything that escapes it.
The caller is a settings page passing a path a person clicked, and ".." in
the wrong place would read or restore from outside the snapshot entirely.
"""
candidate = (root / relative.lstrip("/")).resolve()
if candidate != root.resolve() and root.resolve() not in candidate.parents:
raise BoundaryError("That path is not inside the snapshot.")
return candidate
def browse(config: str, number: str, relative: str) -> dict:
root = snapshot_root(require_config(config), require_number(number))
target = safe_relative(root, relative)
if not target.is_dir():
raise BoundaryError("That is not a folder in this snapshot.")
entries = []
truncated = False
try:
with os.scandir(target) as scan:
for item in scan:
if len(entries) >= BROWSE_LIMIT:
truncated = True
break
try:
is_dir = item.is_dir(follow_symlinks=False)
size = 0 if is_dir else item.stat(follow_symlinks=False).st_size
except OSError:
continue
entries.append({"name": item.name, "directory": is_dir, "bytes": size})
except PermissionError as error:
raise BoundaryError("That folder cannot be read from this snapshot.") from error
except OSError as error:
raise BoundaryError("That folder could not be listed.") from error
entries.sort(key=lambda entry: (not entry["directory"], entry["name"].lower()))
return {"path": relative, "entries": entries, "truncated": truncated, "error": ""}
def restore(config: str, number: str, relative: str) -> dict:
"""Copy something out of a snapshot, keeping whatever is there now.
The current version is moved aside rather than overwritten. A restore that
destroys the thing you were about to compare it against is how people lose
the work they were trying to save.
"""
name = require_config(config)
index = require_number(number)
root = snapshot_root(name, index)
source = safe_relative(root, relative)
if not source.exists():
raise BoundaryError("That is not in this snapshot.")
settings = config_settings(name)
live_root = Path(settings.get("SUBVOLUME", ""))
destination = safe_relative(live_root, relative)
kept = ""
if destination.exists():
kept = str(destination) + f".before-restore-{index}"
suffix = 1
while Path(kept).exists():
suffix += 1
kept = str(destination) + f".before-restore-{index}-{suffix}"
try:
os.rename(destination, kept)
except OSError as error:
raise BoundaryError("The current version could not be set aside.") from error
try:
if source.is_dir():
shutil.copytree(source, destination, symlinks=True)
else:
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination, follow_symlinks=False)
except OSError as error:
raise BoundaryError("That could not be restored.") from error
return {"restored": str(destination), "keptAs": kept}
def create(config: str, description: str) -> None:
if len(description) > 200 or "\n" in description:
raise BoundaryError("That description cannot be used.")
result = run(["snapper", "-c", require_config(config), "create",
"--description", description or "manual snapshot"], timeout=120)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "The snapshot could not be taken."))
def delete(config: str, number: str) -> None:
result = run(["snapper", "-c", require_config(config), "delete",
str(require_number(number))], timeout=120)
if result.returncode != 0:
raise BoundaryError(_refusal(result, "That snapshot could not be removed."))
# The three horizons the page can edit, and the largest number it will accept
# for any of them. 999 hourly snapshots is not a retention policy, it is a typo
# that fills a drive; the page offers a dropdown and this is its ceiling. The
# monthly and yearly limits are left exactly as snapper has them -- nothing here
# writes them, so a config with longer horizons keeps them.
RETENTION_LIMIT = 50
def set_retention(config: str, hourly: str, daily: str, weekly: str) -> None:
values = []
for label, value in (("HOURLY", hourly), ("DAILY", daily), ("WEEKLY", weekly)):
if not str(value).isdigit():
raise BoundaryError("Keep counts must be whole numbers.")
if int(value) > RETENTION_LIMIT:
raise BoundaryError(f"Keep counts go up to {RETENTION_LIMIT}.")
values.append(f"TIMELINE_LIMIT_{label}={int(value)}")
result = run(["snapper", "-c", require_config(config), "set-config", *values])
if result.returncode != 0:
raise BoundaryError(_refusal(result, "The keep counts could not be changed."))
def set_timeline(config: str, enabled: str) -> None:
result = run(["snapper", "-c", require_config(config), "set-config",
f"TIMELINE_CREATE={'yes' if enabled == 'true' else 'no'}"])
if result.returncode != 0:
raise BoundaryError(_refusal(result, "Automatic snapshots could not be changed."))
def _refusal(result: subprocess.CompletedProcess, fallback: str) -> str:
text = (result.stderr or result.stdout or "").strip().splitlines()
if not text:
return fallback
last = text[-1]
if "permission" in last.lower():
return "This account is not allowed to change that configuration."
return last[:200]
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if len(arguments) in (3, 4) and arguments[0] == "browse":
print(json.dumps(browse(arguments[1], arguments[2],
arguments[3] if len(arguments) == 4 else ""),
separators=(",", ":")))
return 0
if len(arguments) == 4 and arguments[0] == "restore":
outcome = restore(arguments[1], arguments[2], arguments[3])
state = snapshot()
state["restored"] = outcome
print(json.dumps(state, separators=(",", ":")))
return 0
if len(arguments) == 3 and arguments[0] == "create":
create(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "delete":
delete(arguments[1], arguments[2])
elif len(arguments) == 5 and arguments[0] == "set-retention":
set_retention(arguments[1], arguments[2], arguments[3], arguments[4])
elif len(arguments) == 3 and arguments[0] == "set-timeline":
set_timeline(arguments[1], arguments[2])
else:
raise BoundaryError(
"Usage: panama-snapshots snapshot | create CONFIG DESCRIPTION | "
"delete CONFIG NUMBER | set-retention CONFIG HOURLY DAILY WEEKLY | "
"set-timeline CONFIG true|false | browse CONFIG NUMBER [PATH] | "
"restore CONFIG NUMBER PATH")
except BoundaryError as error:
try:
state = snapshot()
except BoundaryError:
state = {"configs": [], "unprotected": [], "timelineRunning": False,
"space": {"freeBytes": 0, "totalBytes": 0}}
state["error"] = str(error)
print(json.dumps(state, separators=(",", ":")))
return 0
print(json.dumps(snapshot(), separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))