Clonezilla back up / restore by machine number
Boot menu: "Clonezilla: back up this PC" and "restore this PC" ask for the machine number and boot Clonezilla (64-bit with Debian shim, or 32-bit i686 by cpuid) with ocs_repository=smb3://clonezilla:<pw>@server/clonezilla/, ocs_prerun fetching clonezilla-scripts/pxe-cz.sh and ocs_live_run running it. The script validates the machine number, saves <machine>-<YYYY-MM-DD-HHMM> (server time), writes pxe-meta.txt, runs ocs-chkimg, and restores only that machine's images with -k1 -r after a typed YES. The old entry is "Clonezilla: manual". Playbook: clonezilla Samba user for the [clonezilla] share only, password from clonezilla_smb_password (validated, filled into the menu placeholder), scripts installed to the web root. Webapp: Clonezilla backups page lists image folders and zips grouped by machine number with serial/model/version/check, serial mismatch flag, delete, move to another machine, streamed tar download, disk space bar and an explicit keep-last-N retention with preview. Tests and a VM test plan added.
This commit is contained in:
113
webapp/app.py
113
webapp/app.py
@@ -44,6 +44,7 @@ from werkzeug.utils import secure_filename
|
||||
import config
|
||||
from services import (
|
||||
blancco_evidence,
|
||||
clonezilla_images,
|
||||
hardware_coverage,
|
||||
blancco_report,
|
||||
dell_updates,
|
||||
@@ -551,28 +552,110 @@ def images_rename(image_type):
|
||||
# Routes - Clonezilla Backups
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.route("/backups")
|
||||
def clonezilla_backups():
|
||||
backups = []
|
||||
if os.path.isdir(config.CLONEZILLA_SHARE):
|
||||
for f in sorted(os.listdir(config.CLONEZILLA_SHARE)):
|
||||
fpath = os.path.join(config.CLONEZILLA_SHARE, f)
|
||||
if os.path.isfile(fpath) and f.lower().endswith(".zip"):
|
||||
stat = os.stat(fpath)
|
||||
backups.append({
|
||||
"filename": f,
|
||||
"machine": os.path.splitext(f)[0],
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
})
|
||||
def _backups_page(plan=None, plan_keep=None):
|
||||
images = clonezilla_images.list_images()
|
||||
zips = clonezilla_images.list_zips()
|
||||
groups = clonezilla_images.grouped(images, zips)
|
||||
st = clonezilla_images.settings()
|
||||
return render_template(
|
||||
"backups.html",
|
||||
backups=backups,
|
||||
groups=groups,
|
||||
backups=zips,
|
||||
image_count=len(images),
|
||||
mismatches=sum(1 for g in groups if g["mismatch"]),
|
||||
space=clonezilla_images.disk_space(st["warn_percent"]),
|
||||
cz_settings=st,
|
||||
plan=plan,
|
||||
plan_keep=plan_keep,
|
||||
image_types=config.IMAGE_TYPES,
|
||||
friendly_names=config.FRIENDLY_NAMES,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/backups")
|
||||
def clonezilla_backups():
|
||||
"""Clonezilla image directories and machine-number zips on the share,
|
||||
grouped by machine number (services/clonezilla_images.py)."""
|
||||
return _backups_page()
|
||||
|
||||
|
||||
@app.route("/backups/image/<name>/delete", methods=["POST"])
|
||||
def clonezilla_image_delete(name):
|
||||
try:
|
||||
clonezilla_images.delete_image(name)
|
||||
audit("BACKUP_IMAGE_DELETE", name)
|
||||
flash(f"Deleted image {name}.", "success")
|
||||
except (clonezilla_images.ImageError, OSError) as ex:
|
||||
flash(str(ex), "danger")
|
||||
return redirect(url_for("clonezilla_backups"))
|
||||
|
||||
|
||||
@app.route("/backups/image/<name>/move", methods=["POST"])
|
||||
def clonezilla_image_move(name):
|
||||
try:
|
||||
new = clonezilla_images.move_image(name, request.form.get("machine", ""))
|
||||
audit("BACKUP_IMAGE_MOVE", f"{name} -> {new}")
|
||||
flash(f"Moved {name} to machine {clonezilla_images.norm_machine(request.form.get('machine'))}: now {new}.", "success")
|
||||
except (clonezilla_images.ImageError, OSError) as ex:
|
||||
flash(str(ex), "danger")
|
||||
return redirect(url_for("clonezilla_backups"))
|
||||
|
||||
|
||||
@app.route("/backups/image/<name>/tar")
|
||||
def clonezilla_image_tar(name):
|
||||
"""The image directory as a tar, streamed file by file (no temp copy)."""
|
||||
try:
|
||||
gen = clonezilla_images.tar_stream(name)
|
||||
except clonezilla_images.ImageError as ex:
|
||||
flash(str(ex), "danger")
|
||||
return redirect(url_for("clonezilla_backups"))
|
||||
audit("BACKUP_IMAGE_DOWNLOAD", name)
|
||||
resp = Response(stream_with_context(gen), mimetype="application/x-tar")
|
||||
resp.headers["Content-Disposition"] = f'attachment; filename="{secure_filename(name) or "image"}.tar"'
|
||||
resp.headers["X-Accel-Buffering"] = "no"
|
||||
return resp
|
||||
|
||||
|
||||
@app.route("/backups/settings", methods=["POST"])
|
||||
def clonezilla_settings():
|
||||
try:
|
||||
st = clonezilla_images.save_settings(request.form.get("keep_last", ""), request.form.get("warn_percent", ""))
|
||||
audit("BACKUP_SETTINGS", f"keep_last={st['keep_last']} warn_percent={st['warn_percent']}")
|
||||
flash("Clonezilla backup settings saved." + ("" if not st["keep_last"] else
|
||||
" Nothing is deleted until you press Apply retention and confirm."), "success")
|
||||
except clonezilla_images.ImageError as ex:
|
||||
flash(str(ex), "danger")
|
||||
return redirect(url_for("clonezilla_backups"))
|
||||
|
||||
|
||||
@app.route("/backups/retention")
|
||||
def clonezilla_retention_preview():
|
||||
"""What 'keep last N per machine' would delete. Shows only; the apply
|
||||
is a separate POST of exactly these names."""
|
||||
keep = clonezilla_images.settings()["keep_last"]
|
||||
if not keep:
|
||||
flash("Set Keep last N images per machine first.", "warning")
|
||||
return redirect(url_for("clonezilla_backups"))
|
||||
return _backups_page(plan=clonezilla_images.retention_plan(keep), plan_keep=keep)
|
||||
|
||||
|
||||
@app.route("/backups/retention/apply", methods=["POST"])
|
||||
def clonezilla_retention_apply():
|
||||
keep = clonezilla_images.settings()["keep_last"]
|
||||
names = request.form.getlist("name")
|
||||
if not keep or not names:
|
||||
flash("Nothing to delete.", "warning")
|
||||
return redirect(url_for("clonezilla_backups"))
|
||||
deleted, skipped = clonezilla_images.apply_retention(names, keep)
|
||||
for n in deleted:
|
||||
audit("BACKUP_RETENTION_DELETE", f"{n} (keep last {keep})")
|
||||
msg = f"Retention (keep last {keep}): deleted {len(deleted)} image{'s' if len(deleted) != 1 else ''}."
|
||||
if skipped:
|
||||
msg += f" Kept {len(skipped)} that are no longer due for deletion: {', '.join(skipped[:5])}{'...' if len(skipped) > 5 else ''}."
|
||||
flash(msg, "success" if not skipped else "warning")
|
||||
return redirect(url_for("clonezilla_backups"))
|
||||
|
||||
|
||||
@app.route("/backups/upload", methods=["POST"])
|
||||
def clonezilla_upload():
|
||||
if "backup_file" not in request.files:
|
||||
|
||||
@@ -32,6 +32,8 @@ DISPLAY_TZ = os.environ.get("DISPLAY_TZ", "America/New_York")
|
||||
HARDWARE_STATE = os.environ.get("HARDWARE_STATE", "/var/lib/pxe-webapp/hardware-seen.json")
|
||||
BIOS_DIR = os.environ.get("BIOS_DIR", "/srv/samba/winpeapps/_shared/BIOS")
|
||||
BLANCCO_STATE = os.environ.get("BLANCCO_STATE", "/var/lib/pxe-webapp/blancco-state.json")
|
||||
# Clonezilla backups page settings (keep last N per machine, disk warning).
|
||||
CLONEZILLA_STATE = os.environ.get("CLONEZILLA_STATE", "/var/lib/pxe-webapp/clonezilla-settings.json")
|
||||
# PXE server address the Blancco preferences must point the report share at
|
||||
# (blancco-init.sh greps for it too); checked when a release is staged.
|
||||
BLANCCO_SERVER = os.environ.get("BLANCCO_SERVER", "172.16.9.1")
|
||||
|
||||
544
webapp/services/clonezilla_images.py
Normal file
544
webapp/services/clonezilla_images.py
Normal file
@@ -0,0 +1,544 @@
|
||||
"""Clonezilla images on the clonezilla share, grouped by machine number.
|
||||
|
||||
The boot menu's "Clonezilla: back up this PC" (playbook/clonezilla-scripts/
|
||||
pxe-cz.sh) saves <machine>-<YYYY-MM-DD-HHMM>[-n] image directories with a
|
||||
pxe-meta.txt (key=value: machine, serial, model, disk, date, Clonezilla
|
||||
version, bits, save/check result). Images made by hand (manual mode) have
|
||||
only Clonezilla's own files; their machine number comes from the directory
|
||||
name and serial/model from Clonezilla's Info-dmi.txt when it is there.
|
||||
|
||||
Everything here is defensive: the share is written by PCs over SMB, so a
|
||||
half-written, unreadable or odd directory is listed (or skipped) but never
|
||||
raises out of list_images(). Paths from the browser go through
|
||||
resolve_image(), which only accepts a direct child of the share that is a
|
||||
real (not symlinked) Clonezilla image directory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import config
|
||||
|
||||
MACHINE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,31}$")
|
||||
# <machine>-YYYY-MM-DD-HHMM, optionally -n when two land in one minute.
|
||||
NAME_RE = re.compile(r"^(?P<machine>[A-Za-z0-9][A-Za-z0-9-]*?)-(?P<stamp>\d{4}-\d{2}-\d{2}-\d{4})(?:-(?P<n>\d{1,3}))?$")
|
||||
META_FILE = "pxe-meta.txt"
|
||||
META_MAX = 64 * 1024
|
||||
UNASSIGNED = "" # group key for images with no machine number
|
||||
IN_PROGRESS_SECONDS = 6 * 3600
|
||||
|
||||
DEFAULT_SETTINGS = {"keep_last": 0, "warn_percent": 85}
|
||||
|
||||
|
||||
class ImageError(Exception):
|
||||
"""A refused action (bad name, missing image, clash); the message is shown."""
|
||||
|
||||
|
||||
def share():
|
||||
return config.CLONEZILLA_SHARE
|
||||
|
||||
|
||||
# --- detection and metadata -------------------------------------------------
|
||||
|
||||
def _listdir(path):
|
||||
try:
|
||||
return os.listdir(path)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def is_image_dir(path):
|
||||
"""Clonezilla image dir: a 'disk' or 'parts' file plus an image file
|
||||
(partclone *-ptcl-img*, dd *.dd-img*, ntfsclone/partimage *-img.*) or
|
||||
Clonezilla's clonezilla-img log."""
|
||||
if os.path.islink(path) or not os.path.isdir(path):
|
||||
return False
|
||||
names = _listdir(path)
|
||||
if not ("disk" in names or "parts" in names):
|
||||
return False
|
||||
for n in names:
|
||||
if "-ptcl-img" in n or ".dd-img" in n or "-img." in n or n == "clonezilla-img":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _read_text(path, limit=META_MAX):
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return f.read(limit).decode("utf-8", "replace")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def read_meta(path):
|
||||
"""pxe-meta.txt as a dict ({} when missing or unreadable)."""
|
||||
out = {}
|
||||
for line in _read_text(os.path.join(path, META_FILE)).splitlines():
|
||||
if "=" not in line or line.startswith("#"):
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
k = k.strip()
|
||||
if k and k not in out:
|
||||
out[k] = v.strip()
|
||||
return out
|
||||
|
||||
|
||||
def _dmi_fallback(path):
|
||||
"""Serial, model and maker from Clonezilla's Info-dmi.txt (dmidecode
|
||||
output), System Information block."""
|
||||
text = _read_text(os.path.join(path, "Info-dmi.txt"), 512 * 1024)
|
||||
out = {}
|
||||
block = False
|
||||
for line in text.splitlines():
|
||||
s = line.strip()
|
||||
if s == "System Information":
|
||||
block = True
|
||||
continue
|
||||
if block:
|
||||
if not line.startswith(("\t", " ")) and s:
|
||||
break
|
||||
for key, name in (("serial", "Serial Number"), ("model", "Product Name"), ("manufacturer", "Manufacturer")):
|
||||
if s.startswith(name + ":") and key not in out:
|
||||
out[key] = s.split(":", 1)[1].strip()
|
||||
return out
|
||||
|
||||
|
||||
def _version_fallback(path):
|
||||
"""'clonezilla-live-3.3.3-37-amd64' from clonezilla-img ("Saved by ...")."""
|
||||
m = re.search(r"Saved by (clonezilla-live-[A-Za-z0-9._-]+?)\.?\s", _read_text(os.path.join(path, "clonezilla-img"), 16 * 1024) + " ")
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def _bits_from_version(v):
|
||||
v = (v or "").lower()
|
||||
if "amd64" in v or "x86_64" in v:
|
||||
return "64"
|
||||
if "i686" in v or "i386" in v or "i586" in v:
|
||||
return "32"
|
||||
return ""
|
||||
|
||||
|
||||
def _words(path, name):
|
||||
return _read_text(os.path.join(path, name), 4096).split()
|
||||
|
||||
|
||||
def dir_size(path):
|
||||
"""Bytes on disk, like du (st_blocks), symlinks not followed."""
|
||||
total = 0
|
||||
for root, dirs, files in os.walk(path, followlinks=False):
|
||||
for n in files:
|
||||
try:
|
||||
st = os.lstat(os.path.join(root, n))
|
||||
except OSError:
|
||||
continue
|
||||
total += getattr(st, "st_blocks", 0) * 512 or st.st_size
|
||||
return total
|
||||
|
||||
|
||||
def _parse_stamp(stamp):
|
||||
try:
|
||||
return datetime.strptime(stamp, "%Y-%m-%d-%H%M")
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def norm_machine(m):
|
||||
return (m or "").strip().upper()
|
||||
|
||||
|
||||
def image_info(name, with_size=True):
|
||||
"""One image directory as a dict for the page and the actions."""
|
||||
path = os.path.join(share(), name)
|
||||
meta = read_meta(path)
|
||||
m = NAME_RE.match(name)
|
||||
machine = norm_machine(meta.get("machine"))
|
||||
source = "meta" if machine else ""
|
||||
if not machine and m:
|
||||
machine, source = norm_machine(m.group("machine")), "name"
|
||||
if machine and not MACHINE_RE.match(machine):
|
||||
machine, source = UNASSIGNED, ""
|
||||
try:
|
||||
mtime = os.stat(path).st_mtime
|
||||
except OSError:
|
||||
mtime = 0
|
||||
when = None
|
||||
if meta.get("date_utc"):
|
||||
try:
|
||||
when = datetime.strptime(meta["date_utc"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc).timestamp()
|
||||
except ValueError:
|
||||
when = None
|
||||
if when is None and m and _parse_stamp(m.group("stamp")):
|
||||
when = _parse_stamp(m.group("stamp")).timestamp() # server-local naming time
|
||||
if when is None:
|
||||
when = mtime
|
||||
fb = {} if (meta.get("serial") and meta.get("model")) else _dmi_fallback(path)
|
||||
version = meta.get("clonezilla") or _version_fallback(path)
|
||||
save = meta.get("save", "")
|
||||
check = meta.get("check", "")
|
||||
if save == "failed":
|
||||
status = "failed"
|
||||
elif check == "ok":
|
||||
status = "ok"
|
||||
elif check == "failed":
|
||||
status = "check-failed"
|
||||
elif meta:
|
||||
status = "unchecked"
|
||||
elif time.time() - mtime < IN_PROGRESS_SECONDS:
|
||||
status = "in-progress"
|
||||
else:
|
||||
status = "manual"
|
||||
disk_size = meta.get("disk_size_bytes", "")
|
||||
return {
|
||||
"name": name,
|
||||
"machine": machine,
|
||||
"machine_source": source,
|
||||
"when": when,
|
||||
"mtime": mtime,
|
||||
"stamp": m.group("stamp") if m else "",
|
||||
"suffix": (m.group("n") or "") if m else "",
|
||||
"size": dir_size(path) if with_size else 0,
|
||||
"disks": _words(path, "disk"),
|
||||
"parts": _words(path, "parts"),
|
||||
"serial": meta.get("serial") or fb.get("serial", ""),
|
||||
"model": meta.get("model") or fb.get("model", ""),
|
||||
"manufacturer": meta.get("manufacturer") or fb.get("manufacturer", ""),
|
||||
"clonezilla": version,
|
||||
"bits": meta.get("bits") or _bits_from_version(version),
|
||||
"firmware": meta.get("firmware", ""),
|
||||
"disk_model": meta.get("disk_model", ""),
|
||||
"disk_size": int(disk_size) if disk_size.isdigit() else 0,
|
||||
"date_text": meta.get("date", ""),
|
||||
"check_date": meta.get("check_date", ""),
|
||||
"save": save,
|
||||
"check": check,
|
||||
"status": status,
|
||||
"has_meta": bool(meta),
|
||||
"good": status in ("ok", "unchecked", "manual"),
|
||||
}
|
||||
|
||||
|
||||
def list_images(with_size=True):
|
||||
out = []
|
||||
for name in sorted(_listdir(share())):
|
||||
if name.startswith("."):
|
||||
continue
|
||||
try:
|
||||
if not is_image_dir(os.path.join(share(), name)):
|
||||
continue
|
||||
out.append(image_info(name, with_size))
|
||||
except Exception: # noqa: BLE001 - an odd dir must never break the page
|
||||
continue
|
||||
out.sort(key=lambda i: (i["when"], i["suffix"].zfill(3)), reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def list_zips():
|
||||
out = []
|
||||
for name in sorted(_listdir(share())):
|
||||
p = os.path.join(share(), name)
|
||||
try:
|
||||
if name.lower().endswith(".zip") and os.path.isfile(p) and not os.path.islink(p):
|
||||
st = os.stat(p)
|
||||
out.append({"filename": name, "machine": os.path.splitext(name)[0],
|
||||
"size": st.st_size, "modified": st.st_mtime})
|
||||
except OSError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def grouped(images=None, zips=None):
|
||||
"""Machines, newest activity first (unassigned last). Each: machine,
|
||||
images (newest first), zips, serials, mismatch, latest."""
|
||||
images = list_images() if images is None else images
|
||||
zips = list_zips() if zips is None else zips
|
||||
groups = {}
|
||||
|
||||
def g(key):
|
||||
return groups.setdefault(key, {"machine": key, "images": [], "zips": [], "latest": 0})
|
||||
|
||||
for i in images:
|
||||
grp = g(i["machine"])
|
||||
grp["images"].append(i)
|
||||
grp["latest"] = max(grp["latest"], i["when"] or 0)
|
||||
for z in zips:
|
||||
key = norm_machine(z["machine"]) if MACHINE_RE.match(z["machine"] or "") else UNASSIGNED
|
||||
grp = g(key)
|
||||
grp["zips"].append(z)
|
||||
grp["latest"] = max(grp["latest"], z["modified"] or 0)
|
||||
for grp in groups.values():
|
||||
serials = []
|
||||
for i in grp["images"]:
|
||||
s = (i["serial"] or "").strip().upper()
|
||||
if s and s not in serials:
|
||||
serials.append(s)
|
||||
grp["serials"] = serials
|
||||
# Rows whose serial is not the newest image's: the likely typo.
|
||||
ref = next(((i["serial"] or "").strip().upper() for i in grp["images"] if (i["serial"] or "").strip()), "")
|
||||
for i in grp["images"]:
|
||||
s = (i["serial"] or "").strip().upper()
|
||||
i["serial_differs"] = bool(s and ref and s != ref)
|
||||
grp["mismatch"] = len(serials) > 1
|
||||
grp["size"] = sum(i["size"] for i in grp["images"]) + sum(z["size"] for z in grp["zips"])
|
||||
return sorted(groups.values(), key=lambda x: (x["machine"] == UNASSIGNED, -x["latest"], x["machine"]))
|
||||
|
||||
|
||||
# --- path safety --------------------------------------------------------------
|
||||
|
||||
def resolve_image(name):
|
||||
"""Absolute path of image directory `name` (a direct child of the share),
|
||||
or ImageError."""
|
||||
if (not name or name in (".", "..") or name.startswith(".") or "/" in name or "\\" in name
|
||||
or "\x00" in name or name != os.path.basename(name) or len(name) > 255):
|
||||
raise ImageError("Invalid image name.")
|
||||
root = os.path.realpath(share())
|
||||
path = os.path.join(root, name)
|
||||
if os.path.islink(path):
|
||||
raise ImageError(f"{name} is a link, not an image directory.")
|
||||
if os.path.dirname(os.path.realpath(path)) != root:
|
||||
raise ImageError("Invalid image name.")
|
||||
if not is_image_dir(path):
|
||||
raise ImageError(f"Image not found: {name}")
|
||||
return path
|
||||
|
||||
|
||||
# --- actions --------------------------------------------------------------------
|
||||
|
||||
def delete_image(name):
|
||||
path = resolve_image(name)
|
||||
shutil.rmtree(path)
|
||||
return name
|
||||
|
||||
|
||||
def _write_meta(path, updates):
|
||||
meta_path = os.path.join(path, META_FILE)
|
||||
lines = _read_text(meta_path).splitlines()
|
||||
keys = set(updates)
|
||||
kept = [l for l in lines if l.split("=", 1)[0].strip() not in keys]
|
||||
kept += [f"{k}={v}" for k, v in updates.items()]
|
||||
fd, tmp = tempfile.mkstemp(dir=path, prefix=".pxe-meta-")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(kept) + "\n")
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, meta_path)
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def move_image(name, new_machine):
|
||||
"""Give image `name` to machine `new_machine`: rename the directory to
|
||||
<new>-<same stamp> and set machine= in pxe-meta.txt. Returns the new name."""
|
||||
path = resolve_image(name)
|
||||
new_machine = norm_machine(new_machine)
|
||||
if not MACHINE_RE.match(new_machine):
|
||||
raise ImageError("Machine number: letters, digits and dash only (up to 32, not starting with a dash).")
|
||||
info = image_info(name, with_size=False)
|
||||
stamp = info["stamp"] or datetime.fromtimestamp(info["when"] or time.time()).strftime("%Y-%m-%d-%H%M")
|
||||
existing = {n.lower() for n in _listdir(share())}
|
||||
base = f"{new_machine}-{stamp}"
|
||||
new = base if not info["suffix"] else f"{base}-{info['suffix']}"
|
||||
n = 2
|
||||
# SMB is case-insensitive: a case twin is the same name to Windows.
|
||||
while new.lower() in existing and new.lower() != name.lower():
|
||||
new = f"{base}-{n}"
|
||||
n += 1
|
||||
new_path = os.path.join(os.path.dirname(path), new)
|
||||
if new != name:
|
||||
if os.path.lexists(new_path) and new.lower() != name.lower():
|
||||
raise ImageError(f"{new} already exists.")
|
||||
os.rename(path, new_path)
|
||||
updates = {"machine": new_machine}
|
||||
if info["machine"] and info["machine"] != new_machine:
|
||||
updates["moved_from"] = info["machine"]
|
||||
_write_meta(new_path, updates)
|
||||
return new
|
||||
|
||||
|
||||
# --- tar download (streamed, no temp copy) -------------------------------------
|
||||
|
||||
CHUNK = 1024 * 1024
|
||||
|
||||
|
||||
def tar_stream(name):
|
||||
"""Generator of a POSIX (pax) tar of image `name`, read file by file in
|
||||
1 MiB chunks: nothing is staged. A file that shrinks while it is read is
|
||||
padded with zeros so the archive stays well-formed."""
|
||||
path = resolve_image(name)
|
||||
|
||||
def header(ti):
|
||||
return ti.tobuf(tarfile.PAX_FORMAT, "utf-8", "surrogateescape")
|
||||
|
||||
def gen():
|
||||
written = 0
|
||||
for root, dirs, files in os.walk(path, followlinks=False):
|
||||
dirs.sort()
|
||||
rel_root = os.path.relpath(root, path)
|
||||
arc_root = name if rel_root == "." else f"{name}/{rel_root}"
|
||||
ti = tarfile.TarInfo(arc_root)
|
||||
ti.type = tarfile.DIRTYPE
|
||||
ti.mode = 0o755
|
||||
try:
|
||||
ti.mtime = int(os.lstat(root).st_mtime)
|
||||
except OSError:
|
||||
ti.mtime = int(time.time())
|
||||
buf = header(ti)
|
||||
written += len(buf)
|
||||
yield buf
|
||||
for n in sorted(files):
|
||||
full = os.path.join(root, n)
|
||||
try:
|
||||
st = os.lstat(full)
|
||||
except OSError:
|
||||
continue
|
||||
if not os.path.isfile(full) or os.path.islink(full):
|
||||
continue # sockets, fifos, links: not part of an image
|
||||
ti = tarfile.TarInfo(f"{arc_root}/{n}")
|
||||
ti.size = st.st_size
|
||||
ti.mtime = int(st.st_mtime)
|
||||
ti.mode = 0o644
|
||||
try:
|
||||
f = open(full, "rb")
|
||||
except OSError:
|
||||
continue
|
||||
buf = header(ti)
|
||||
written += len(buf)
|
||||
yield buf
|
||||
left = ti.size
|
||||
with f:
|
||||
while left > 0:
|
||||
chunk = f.read(min(CHUNK, left))
|
||||
if not chunk:
|
||||
break
|
||||
left -= len(chunk)
|
||||
written += len(chunk)
|
||||
yield chunk
|
||||
while left > 0:
|
||||
pad = min(CHUNK, left)
|
||||
left -= pad
|
||||
written += pad
|
||||
yield b"\0" * pad
|
||||
rem = ti.size % tarfile.BLOCKSIZE
|
||||
if rem:
|
||||
pad = tarfile.BLOCKSIZE - rem
|
||||
written += pad
|
||||
yield b"\0" * pad
|
||||
end = b"\0" * (tarfile.BLOCKSIZE * 2)
|
||||
written += len(end)
|
||||
rem = written % tarfile.RECORDSIZE
|
||||
if rem:
|
||||
end += b"\0" * (tarfile.RECORDSIZE - rem)
|
||||
yield end
|
||||
|
||||
return gen()
|
||||
|
||||
|
||||
# --- disk space -----------------------------------------------------------------
|
||||
|
||||
def disk_space(warn_percent=None):
|
||||
warn = settings()["warn_percent"] if warn_percent is None else warn_percent
|
||||
try:
|
||||
du = shutil.disk_usage(share())
|
||||
except OSError:
|
||||
return None
|
||||
pct = (du.used / du.total * 100) if du.total else 0
|
||||
return {"total": du.total, "used": du.used, "free": du.free, "percent": pct,
|
||||
"warn_percent": warn, "warn": pct >= warn}
|
||||
|
||||
|
||||
# --- settings and retention ---------------------------------------------------
|
||||
|
||||
def _settings_path():
|
||||
return config.CLONEZILLA_STATE
|
||||
|
||||
|
||||
def settings():
|
||||
out = dict(DEFAULT_SETTINGS)
|
||||
try:
|
||||
with open(_settings_path(), encoding="utf-8") as f:
|
||||
saved = json.load(f)
|
||||
if isinstance(saved, dict):
|
||||
for k in DEFAULT_SETTINGS:
|
||||
if isinstance(saved.get(k), int) and not isinstance(saved.get(k), bool):
|
||||
out[k] = saved[k]
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def save_settings(keep_last, warn_percent):
|
||||
try:
|
||||
keep_last = int(keep_last)
|
||||
warn_percent = int(warn_percent)
|
||||
except (TypeError, ValueError):
|
||||
raise ImageError("Settings must be whole numbers.") from None
|
||||
if not 0 <= keep_last <= 100:
|
||||
raise ImageError("Keep last N images: 0 (off) to 100.")
|
||||
if not 50 <= warn_percent <= 99:
|
||||
raise ImageError("Disk space warning: 50 to 99 percent.")
|
||||
path = _settings_path()
|
||||
folder = os.path.dirname(path) or "."
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=folder, prefix=".tmp-")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump({"keep_last": keep_last, "warn_percent": warn_percent}, f, indent=1)
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
raise
|
||||
return settings()
|
||||
|
||||
|
||||
def retention_plan(keep_last=None, images=None):
|
||||
"""What 'keep the last N images per machine' would delete, never applied
|
||||
here. Per machine number (unassigned images are never touched): keep the
|
||||
N newest GOOD images (saved and not failed the check; manual images
|
||||
count as good), delete every image OLDER than the oldest one kept.
|
||||
Newer failed or in-progress images stay, so a backup being written now
|
||||
is never removed and never pushes a good one out."""
|
||||
keep_last = settings()["keep_last"] if keep_last is None else keep_last
|
||||
if not keep_last or keep_last < 1:
|
||||
return []
|
||||
images = list_images() if images is None else images
|
||||
plan = []
|
||||
for grp in grouped(images, []):
|
||||
if grp["machine"] == UNASSIGNED:
|
||||
continue
|
||||
imgs = grp["images"] # newest first
|
||||
good = [i for i in imgs if i["good"]]
|
||||
if len(good) <= keep_last:
|
||||
continue
|
||||
oldest_kept = good[keep_last - 1]
|
||||
cut = imgs.index(oldest_kept)
|
||||
for i in imgs[cut + 1:]:
|
||||
plan.append({"machine": grp["machine"], "name": i["name"], "when": i["when"],
|
||||
"size": i["size"], "status": i["status"]})
|
||||
return plan
|
||||
|
||||
|
||||
def apply_retention(names, keep_last=None):
|
||||
"""Delete the posted names that are STILL in the plan (a stale preview
|
||||
cannot delete an image that has become one of the N newest)."""
|
||||
planned = {p["name"] for p in retention_plan(keep_last)}
|
||||
deleted, skipped = [], []
|
||||
for n in names:
|
||||
if n in planned:
|
||||
try:
|
||||
delete_image(n)
|
||||
deleted.append(n)
|
||||
except (ImageError, OSError):
|
||||
skipped.append(n)
|
||||
else:
|
||||
skipped.append(n)
|
||||
return deleted, skipped
|
||||
@@ -2,75 +2,222 @@
|
||||
{% import '_ui.html' as ui %}
|
||||
{% block title %}Clonezilla backups - PXE Manager{% endblock %}
|
||||
|
||||
{# Clonezilla backups: one line per zip on the share (machine number, file,
|
||||
size, date). Download is the row's action; Delete is in the menu and opens
|
||||
the confirm modal. #}
|
||||
{# Clonezilla backups, grouped by machine number (newest activity first):
|
||||
image directories saved by the boot menu's "Clonezilla: back up this PC"
|
||||
(or by hand) and the older machine-number .zip files. One line per
|
||||
image; its details (disks, Clonezilla version, check) are in the
|
||||
expandable body; Download is the row's action, Move and Delete are in
|
||||
the menu and open confirm modals. #}
|
||||
|
||||
{% macro gb(n) -%}
|
||||
{%- if n >= 1e12 %}{{ "%.2f"|format(n / 1e12) }} TB{% elif n >= 1e9 %}{{ "%.1f"|format(n / 1e9) }} GB{% else %}{{ "%.0f"|format(n / 1e6) }} MB{% endif -%}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro status_chip(i) -%}
|
||||
{%- if i.status == 'ok' %}{{ ui.chip('Checked', 'ok', 'Clonezilla image check passed' ~ (' ' ~ i.check_date if i.check_date else '')) }}
|
||||
{%- elif i.status == 'check-failed' %}{{ ui.chip('Check failed', 'fail', 'The image check after saving failed: do not rely on this image') }}
|
||||
{%- elif i.status == 'failed' %}{{ ui.chip('Save failed', 'fail', 'Clonezilla reported an error while saving: partial image') }}
|
||||
{%- elif i.status == 'unchecked' %}{{ ui.chip('Not checked', 'warn', 'Saved, but the image check did not run') }}
|
||||
{%- elif i.status == 'in-progress' %}{{ ui.chip('In progress?', 'run', 'No pxe-meta.txt yet and changed in the last 6 hours: probably still being written') }}
|
||||
{%- else %}{{ ui.chip('Manual', 'neutral', 'Saved without the PXE menu script: no check result recorded') }}{% endif -%}
|
||||
{%- endmacro %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.bgrid { display: grid; grid-template-columns: 180px minmax(0, 1fr) 90px 190px; align-items: center;
|
||||
gap: 0 14px; padding: 11px 160px 11px 24px; font-size: 14px; min-height: 36px; }
|
||||
.bhead { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px; padding-bottom: 6px; min-height: 0; }
|
||||
.b-mach { font-weight: 600; }
|
||||
.b-size { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.b-when { font-size: 13px; color: var(--muted); white-space: nowrap; }
|
||||
.igrid { display: grid; grid-template-columns: 186px minmax(0, 1fr) 140px 84px 118px 14px; align-items: center;
|
||||
gap: 0 14px; padding: 11px 184px 11px 24px; font-size: 14px; min-height: 30px; }
|
||||
.ihead { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px; padding-bottom: 6px; min-height: 0; }
|
||||
.i-when { font-size: 13px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.i-serial.odd { color: var(--warn-text); font-weight: 600; }
|
||||
.i-size { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.mach { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 14px 24px 10px; border-top: 1px solid var(--line-2); background: var(--surface-2); cursor: pointer; }
|
||||
details.mgroup > summary { list-style: none; }
|
||||
details.mgroup > summary::-webkit-details-marker { display: none; }
|
||||
.mach h3 { margin: 0; font-size: 15px; font-weight: 600; }
|
||||
.mach .sp { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.ibody { padding: 2px 24px 16px 208px; font-size: 13px; display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 16px; }
|
||||
.ibody dt { color: var(--muted); font-weight: 400; }
|
||||
.ibody dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.bl-empty { padding: 28px 24px; text-align: center; color: var(--muted); border-top: 1px solid var(--line); }
|
||||
.bnote { padding: 18px 24px; font-size: 13px; color: var(--text-2); display: flex; flex-direction: column; gap: 6px; }
|
||||
.bnote p { margin: 0; }
|
||||
@container (max-width: 820px) {
|
||||
.bgrid { grid-template-columns: minmax(0, 1fr) 80px; padding: 11px 160px 11px 18px; gap: 2px 12px; }
|
||||
.bgrid .b-file, .bgrid .b-when { grid-column: 1 / -1; }
|
||||
.bhead { display: none; }
|
||||
.space { padding: 16px 24px; display: flex; flex-direction: column; gap: 8px; font-size: 13px; }
|
||||
.space .row1 { display: flex; flex-wrap: wrap; gap: 4px 16px; align-items: baseline; }
|
||||
.sbar { height: 10px; border-radius: 5px; background: var(--surface-3); box-shadow: inset 0 0 0 1px var(--line-2); overflow: hidden; }
|
||||
.sbar > i { display: block; height: 100%; background: var(--ok); }
|
||||
.sbar.warn > i { background: var(--act-track); }
|
||||
.plan { padding: 16px 24px; display: flex; flex-direction: column; gap: 10px; font-size: 13px; }
|
||||
.plan ul { margin: 0; padding-left: 18px; max-height: 18rem; overflow-y: auto; }
|
||||
.plan .btns { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.setform { width: min(340px, calc(100vw - 32px)); padding: 14px; gap: 10px; }
|
||||
.setform label { display: flex; flex-direction: column; gap: 4px; }
|
||||
@container (max-width: 1000px) {
|
||||
.igrid { grid-template-columns: 186px minmax(0, 1fr) 118px 14px; }
|
||||
.igrid .i-serial, .igrid .i-size { display: none; }
|
||||
.ibody { padding-left: 24px; }
|
||||
}
|
||||
@container (max-width: 520px) {
|
||||
/* The file is the machine number plus .zip; the expandable-free row keeps machine, size and date. */
|
||||
.bgrid { grid-template-columns: minmax(0, 1fr); padding: 12px 150px 12px 16px; }
|
||||
.bgrid .b-file { display: none; }
|
||||
.b-mach .trunc { white-space: normal; overflow-wrap: anywhere; }
|
||||
.b-size { text-align: left; }
|
||||
.ui-row > .act { right: 12px; top: 12px; }
|
||||
@container (max-width: 640px) {
|
||||
.igrid { grid-template-columns: minmax(0, 1fr) auto 14px; padding: 11px 16px; gap: 2px 10px; }
|
||||
.igrid .i-when { grid-column: 1 / -1; }
|
||||
.igrid .i-size { display: none; }
|
||||
.ihead { display: none; }
|
||||
.ui-row > .act { position: static; display: flex; justify-content: flex-end; padding: 0 16px 10px; }
|
||||
.ibody { padding: 2px 16px 14px; grid-template-columns: minmax(0, 1fr); }
|
||||
.ibody dt { margin-top: 4px; }
|
||||
.mach, .space, .plan { padding-left: 16px; padding-right: 16px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% call ui.head('Clonezilla backups', 'Tools', (backups | length) ~ ' backup' ~ ('s' if backups | length != 1) ~ ' on the share') %}
|
||||
{% set nmach = groups | rejectattr('machine', 'equalto', '') | list | length %}
|
||||
{% call ui.head('Clonezilla backups', 'Tools', image_count ~ ' image' ~ ('s' if image_count != 1) ~ ' and ' ~ (backups | length) ~ ' zip' ~ ('s' if backups | length != 1) ~ ' for ' ~ nmach ~ ' machine' ~ ('s' if nmach != 1) ~ ' on the share') %}
|
||||
<details class="more">
|
||||
<summary class="ui-btn">Settings</summary>
|
||||
<form class="menu setform" method="post" action="{{ url_for('clonezilla_settings') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<label for="keepLast" class="sub">Keep last N images per machine (0 = off)
|
||||
<input id="keepLast" type="number" name="keep_last" min="0" max="100" value="{{ cz_settings.keep_last }}" class="field" required></label>
|
||||
<label for="warnPct" class="sub">Warn when the share is this full (%)
|
||||
<input id="warnPct" type="number" name="warn_percent" min="50" max="99" value="{{ cz_settings.warn_percent }}" class="field" required></label>
|
||||
<div class="sub" style="white-space: normal">Nothing is deleted automatically: Apply retention shows what would go and asks first.</div>
|
||||
<button type="submit" class="ui-btn pri">Save settings</button>
|
||||
</form>
|
||||
</details>
|
||||
{% if cz_settings.keep_last %}
|
||||
<a class="ui-btn" href="{{ url_for('clonezilla_retention_preview') }}">Apply retention...</a>
|
||||
{% endif %}
|
||||
<button type="button" class="ui-btn pri" data-bs-toggle="modal" data-bs-target="#uploadModal">Upload backup</button>
|
||||
{% endcall %}
|
||||
|
||||
<div class="ui-stack">
|
||||
<section class="panel ui-list" aria-labelledby="bl-h">
|
||||
<h2 id="bl-h" class="vh">Machine backups</h2>
|
||||
{% if backups %}
|
||||
<div class="bgrid bhead" aria-hidden="true"><span>Machine</span><span class="b-file">File</span><span class="b-size">Size</span><span class="b-when">Modified</span></div>
|
||||
{% for b in backups %}
|
||||
<div class="ui-row">
|
||||
<div class="bgrid">
|
||||
<span class="b-mach mono tip" data-tip="{{ b.machine }}"><span class="trunc">{{ b.machine }}</span></span>
|
||||
<span class="b-file tip" data-tip="{{ b.filename }}"><span class="trunc mono sub">{{ b.filename }}</span></span>
|
||||
<span class="b-size">{{ "%.1f"|format(b.size / 1073741824) }} GB</span>
|
||||
<span class="b-when mono">{{ b.modified | timestamp_fmt }}</span>
|
||||
</div>
|
||||
<div class="act">
|
||||
<a class="ui-btn" href="{{ url_for('clonezilla_download', filename=b.filename) }}">Download</a>
|
||||
{% call ui.more(b.machine) %}
|
||||
<button type="button" class="copy-btn" data-copy-text="{{ b.filename }}">Copy file name</button>
|
||||
<div class="sep"></div>
|
||||
<button type="button" class="del" data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ b.filename }}" data-machine="{{ b.machine }}">Delete backup...</button>
|
||||
{% endcall %}
|
||||
|
||||
{% if plan is not none %}
|
||||
<section class="panel plan" aria-labelledby="plan-h">
|
||||
<h2 id="plan-h" class="sec" style="font-size: 15px; margin: 0">Apply retention: keep the last {{ plan_keep }} image{{ 's' if plan_keep != 1 }} per machine</h2>
|
||||
{% if plan %}
|
||||
<div class="notice bad">These {{ plan | length }} image{{ 's' if plan | length != 1 }} ({{ gb(plan | sum(attribute='size')) }}) will be DELETED. This cannot be undone.</div>
|
||||
<p class="sub" style="margin: 0">Kept per machine: the {{ plan_keep }} newest images that saved and passed (or skipped) the check. Newer failed or in-progress images are kept too. Images with no machine number are never touched.</p>
|
||||
<ul>
|
||||
{% for p in plan %}<li><span class="mono">{{ p.name }}</span> <span class="sub">machine {{ p.machine }} | {{ p.when | timestamp_fmt }} | {{ gb(p.size) }}</span></li>{% endfor %}
|
||||
</ul>
|
||||
<form method="post" action="{{ url_for('clonezilla_retention_apply') }}" onsubmit="return confirm('Delete {{ plan | length }} image(s)? This cannot be undone.');">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
{% for p in plan %}<input type="hidden" name="name" value="{{ p.name }}">{% endfor %}
|
||||
<div class="btns">
|
||||
<a class="ui-btn pri" href="{{ url_for('clonezilla_backups') }}">Cancel</a>
|
||||
<button type="submit" class="ui-btn danger">Delete {{ plan | length }} image{{ 's' if plan | length != 1 }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="notice info">Nothing to delete: no machine has more than {{ plan_keep }} good image{{ 's' if plan_keep != 1 }}.</div>
|
||||
<div class="btns"><a class="ui-btn" href="{{ url_for('clonezilla_backups') }}">Back</a></div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if space %}
|
||||
<section class="panel space" aria-labelledby="sp-h">
|
||||
<h2 id="sp-h" class="vh">Share disk space</h2>
|
||||
<div class="row1">
|
||||
<strong>Share disk space</strong>
|
||||
<span>{{ gb(space.used) }} used of {{ gb(space.total) }} ({{ "%.0f"|format(space.percent) }}%)</span>
|
||||
<span class="sub">{{ gb(space.free) }} free</span>
|
||||
{% if space.warn %}{{ ui.chip('Over ' ~ space.warn_percent ~ '%', 'warn') }}{% endif %}
|
||||
</div>
|
||||
<div class="sbar{{ ' warn' if space.warn }}" role="meter" aria-label="Share disk space used" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ '%.0f'|format(space.percent) }}"><i style="width: {{ '%.1f'|format(space.percent) }}%"></i></div>
|
||||
{% if space.warn %}<div class="notice">The share is {{ "%.0f"|format(space.percent) }}% full (warning at {{ space.warn_percent }}%). Delete old images, or set Keep last N in Settings and Apply retention. A backup that runs out of space fails part-way.</div>{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if mismatches %}
|
||||
<div class="notice" role="status">{{ mismatches }} machine{{ 's have' if mismatches != 1 else ' has' }} images with different serial numbers (marked Serial mismatch below): a typo in the machine number, or the PC was replaced. Move the wrong images to the right machine number.</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="panel ui-list" aria-labelledby="bl-h">
|
||||
<h2 id="bl-h" class="vh">Backups by machine</h2>
|
||||
{% if groups %}
|
||||
<div class="igrid ihead" aria-hidden="true"><span>Saved</span><span>Model</span><span class="i-serial">Serial</span><span class="i-size">Size</span><span>Check</span><span></span></div>
|
||||
{% for g in groups %}
|
||||
{% set label = g.machine or 'No machine number' %}
|
||||
<details class="mgroup" {% if loop.index <= 6 or g.mismatch %}open{% endif %} data-machine="{{ g.machine }}">
|
||||
<summary class="mach">
|
||||
<h3 class="{{ 'mono' if g.machine }}">{{ label }}</h3>
|
||||
<span class="sub">{{ g.images | length }} image{{ 's' if g.images | length != 1 }}{% if g.zips %}, {{ g.zips | length }} zip{{ 's' if g.zips | length != 1 }}{% endif %} | {{ gb(g.size) }}</span>
|
||||
<span class="sp">
|
||||
{% if g.mismatch %}{{ ui.chip('Serial mismatch', 'warn', 'Serials: ' ~ (g.serials | join(', '))) }}<span class="sub mono">{{ g.serials | join(', ') }}</span>{% endif %}
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
{% for i in g.images %}
|
||||
<div class="ui-row irow" data-name="{{ i.name }}">
|
||||
<details>
|
||||
<summary class="igrid">
|
||||
<span class="i-when mono">{{ i.when | timestamp_fmt }}</span>
|
||||
<span class="tip" data-tip="{{ i.name }}"><span class="trunc">{{ i.model or i.name }}</span></span>
|
||||
<span class="i-serial mono tip{{ ' odd' if i.serial_differs }}" data-tip="{{ (i.serial ~ (' (differs from the newest image of this machine)' if i.serial_differs else '')) if i.serial else 'no serial recorded' }}"><span class="trunc">{{ i.serial or '-' }}</span></span>
|
||||
<span class="i-size">{{ gb(i.size) }}</span>
|
||||
<span>{{ status_chip(i) }}</span>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<dl class="ibody">
|
||||
<dt>Image</dt><dd class="mono">{{ i.name }}</dd>
|
||||
<dt>Machine</dt><dd><span class="mono">{{ i.machine or '-' }}</span> <span class="sub">({{ {'meta': 'from pxe-meta.txt', 'name': 'from the folder name'}.get(i.machine_source, 'unknown: move it to a machine number') }})</span></dd>
|
||||
<dt>PC</dt><dd>{{ i.manufacturer }} {{ i.model or '-' }} | serial <span class="mono">{{ i.serial or '-' }}</span>{% if i.serial_differs %} {{ ui.chip('Different serial', 'warn', 'Not the serial of the newest image of this machine') }}{% endif %}</dd>
|
||||
<dt>Size</dt><dd>{{ gb(i.size) }}</dd>
|
||||
<dt>Disk</dt><dd><span class="mono">{{ i.disks | join(' ') or '-' }}</span>{% if i.disk_model %} | {{ i.disk_model }}{% endif %}{% if i.disk_size %} | {{ gb(i.disk_size) }}{% endif %}</dd>
|
||||
<dt>Partitions</dt><dd class="mono">{{ i.parts | join(' ') or '-' }}</dd>
|
||||
<dt>Clonezilla</dt><dd>{{ i.clonezilla or '-' }}{% if i.bits %} | {{ i.bits }}-bit{% endif %}{% if i.firmware %} | {{ i.firmware | upper }}{% endif %}</dd>
|
||||
<dt>Saved</dt><dd>{{ i.date_text or (i.when | timestamp_fmt) }}{% if i.save == 'failed' %} | <strong>save failed</strong>{% endif %}</dd>
|
||||
<dt>Check</dt><dd>{{ {'ok': 'passed', 'failed': 'FAILED', 'not-run': 'not run'}.get(i.check, 'no result recorded') }}{% if i.check_date %} ({{ i.check_date }}){% endif %}</dd>
|
||||
</dl>
|
||||
</details>
|
||||
<div class="act">
|
||||
<a class="ui-btn" href="{{ url_for('clonezilla_image_tar', name=i.name) }}" title="Download the image folder as a .tar (streamed)">Download .tar</a>
|
||||
{% call ui.more(i.name) %}
|
||||
<button type="button" class="copy-btn" data-copy-text="{{ i.name }}">Copy image name</button>
|
||||
<button type="button" data-bs-toggle="modal" data-bs-target="#moveModal" data-name="{{ i.name }}" data-machine="{{ i.machine }}">Move to another machine...</button>
|
||||
<div class="sep"></div>
|
||||
<button type="button" class="del" data-bs-toggle="modal" data-bs-target="#imageDeleteModal"
|
||||
data-name="{{ i.name }}" data-machine="{{ label }}">Delete image...</button>
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% for b in g.zips %}
|
||||
<div class="ui-row zrow">
|
||||
<div class="igrid">
|
||||
<span class="i-when mono">{{ b.modified | timestamp_fmt }}</span>
|
||||
<span class="tip" data-tip="{{ b.filename }}"><span class="trunc mono">{{ b.filename }}</span></span>
|
||||
<span class="i-serial sub">-</span>
|
||||
<span class="i-size">{{ gb(b.size) }}</span>
|
||||
<span>{{ ui.chip('Zip', 'neutral', 'Uploaded .zip backup') }}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="act">
|
||||
<a class="ui-btn" href="{{ url_for('clonezilla_download', filename=b.filename) }}">Download</a>
|
||||
{% call ui.more(b.machine) %}
|
||||
<button type="button" class="copy-btn" data-copy-text="{{ b.filename }}">Copy file name</button>
|
||||
<div class="sep"></div>
|
||||
<button type="button" class="del" data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ b.filename }}" data-machine="{{ b.machine }}">Delete backup...</button>
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</details>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="bl-empty">No backups found. Upload a Clonezilla backup .zip to get started.</div>
|
||||
<div class="bl-empty">No backups yet. Boot a PC from the network and pick <strong>Clonezilla: back up this PC</strong>, or upload a .zip.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel bnote" aria-labelledby="bn-h">
|
||||
<h2 id="bn-h" class="sec" style="font-size: 15px">Naming</h2>
|
||||
<p>Name backup files with the machine number, for example <span class="mono">6501.zip</span>. The machine column is the file name without <span class="mono">.zip</span>.</p>
|
||||
<p>The Samba share <span class="mono">\\pxe-server\clonezilla</span> is also on the network for direct Clonezilla save and restore.</p>
|
||||
<h2 id="bn-h" class="sec" style="font-size: 15px">How backups are named</h2>
|
||||
<p>The boot menu's <strong>Clonezilla: back up this PC</strong> asks for the machine number and saves <span class="mono"><machine>-<YYYY-MM-DD-HHMM></span> (for example <span class="mono">6501-2026-10-15-1403</span>) to <span class="mono">\\pxe-server\clonezilla</span>, then checks it. <strong>Clonezilla: restore this PC</strong> lists only that machine's images, newest first.</p>
|
||||
<p>Images saved by hand (manual mode) are grouped by the start of their folder name; move them to a machine number if they show under No machine number. Uploaded zips are named with the machine number, for example <span class="mono">6501.zip</span>.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +255,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<!-- Delete zip Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteTitle">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
@@ -130,6 +277,57 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete image Confirmation Modal -->
|
||||
<div class="modal fade" id="imageDeleteModal" tabindex="-1" aria-labelledby="imageDeleteTitle">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="imageDeleteForm" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="imageDeleteTitle">Delete image</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Delete the image <strong class="mono" id="imageDeleteName"></strong> of machine <strong id="imageDeleteMachine"></strong>?</p>
|
||||
<p class="muted mb-0">The whole image folder is removed from the share. This cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="ui-btn pri" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="ui-btn danger">Delete image</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Move image Modal -->
|
||||
<div class="modal fade" id="moveModal" tabindex="-1" aria-labelledby="moveTitle">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="moveForm" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="moveTitle">Move image to another machine</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-2">Image <strong class="mono" id="moveName"></strong></p>
|
||||
<div class="form-field mb-0">
|
||||
<label for="moveMachine" class="form-label">New machine number</label>
|
||||
<input type="text" class="form-control mono" id="moveMachine" name="machine" required maxlength="32"
|
||||
pattern="[A-Za-z0-9][A-Za-z0-9\-]{0,31}" autocomplete="off" spellcheck="false">
|
||||
<div class="form-text">Letters, digits and dash. The folder is renamed to <span class="mono"><machine>-<same date></span> and its pxe-meta.txt updated, so restore by machine number finds it.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="ui-btn" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="ui-btn pri">Move</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
@@ -141,5 +339,21 @@ document.getElementById('deleteModal').addEventListener('show.bs.modal', functio
|
||||
document.getElementById('deleteMachine').textContent = machine;
|
||||
document.getElementById('deleteForm').action = "{{ url_for('clonezilla_delete', filename='__F__') }}".replace('__F__', encodeURIComponent(filename));
|
||||
});
|
||||
document.getElementById('imageDeleteModal').addEventListener('show.bs.modal', function (event) {
|
||||
var btn = event.relatedTarget;
|
||||
var name = btn.getAttribute('data-name');
|
||||
document.getElementById('imageDeleteName').textContent = name;
|
||||
document.getElementById('imageDeleteMachine').textContent = btn.getAttribute('data-machine');
|
||||
document.getElementById('imageDeleteForm').action = "{{ url_for('clonezilla_image_delete', name='__N__') }}".replace('__N__', encodeURIComponent(name));
|
||||
});
|
||||
document.getElementById('moveModal').addEventListener('show.bs.modal', function (event) {
|
||||
var btn = event.relatedTarget;
|
||||
var name = btn.getAttribute('data-name');
|
||||
document.getElementById('moveName').textContent = name;
|
||||
var inp = document.getElementById('moveMachine');
|
||||
inp.value = btn.getAttribute('data-machine') || '';
|
||||
document.getElementById('moveForm').action = "{{ url_for('clonezilla_image_move', name='__N__') }}".replace('__N__', encodeURIComponent(name));
|
||||
setTimeout(function () { inp.focus(); inp.select(); }, 200);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
422
webapp/tests/test_clonezilla_images.py
Normal file
422
webapp/tests/test_clonezilla_images.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""Clonezilla backups page: image listing, grouping, metadata, serial
|
||||
mismatch, delete/move safety, retention preview/apply, tar download.
|
||||
|
||||
Run from the webapp folder:
|
||||
PYTHONPATH=.:<deps> python -m unittest discover -s tests -v
|
||||
|
||||
Every config path points into a temp folder (env vars before config is
|
||||
imported, then patched per test), so nothing on the host is touched.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
_T = tempfile.mkdtemp(prefix="pxe-cz-test-")
|
||||
for _k, _sub in {"SAMBA_SHARE": "samba", "CLONEZILLA_SHARE": "cz", "BLANCCO_REPORTS": "br",
|
||||
"ENROLLMENT_SHARE": "enr", "WEB_ROOT": "www", "IMAGING_DIR": "img", "UPDATES_DIR": "upd",
|
||||
"TFTP_ROOT": "tftp", "BIOS_DIR": "bios", "UPLOAD_DIR": "upl", "SAMBA_LOG_DIR": "smblog"}.items():
|
||||
os.environ.setdefault(_k, os.path.join(_T, _sub))
|
||||
os.makedirs(os.environ[_k], exist_ok=True)
|
||||
for _k in ("AUDIT_LOG", "HARDWARE_STATE", "BLANCCO_STATE", "DNSMASQ_LEASES", "APACHE_ACCESS_LOG",
|
||||
"DNSMASQ_SYSLOG", "NIC_STATUS_FILE", "NIC_CONF", "DNSMASQ_NIC_CONF", "DNSMASQ_CONF", "CLONEZILLA_STATE"):
|
||||
os.environ.setdefault(_k, os.path.join(_T, _k.lower()))
|
||||
os.makedirs(os.path.join(os.environ["ENROLLMENT_SHARE"], "ppkgs"), exist_ok=True)
|
||||
|
||||
WEBAPP = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, WEBAPP)
|
||||
os.chdir(WEBAPP)
|
||||
|
||||
import config # noqa: E402
|
||||
import app as A # noqa: E402
|
||||
from services import clonezilla_images as CZ # noqa: E402
|
||||
|
||||
DMI = """This image was saved from this machine with DMI info at 2025-01-01:
|
||||
# dmidecode 3.5
|
||||
Handle 0x0001, DMI type 1, 27 bytes
|
||||
System Information
|
||||
\tManufacturer: Dell Inc.
|
||||
\tProduct Name: OptiPlex 7010
|
||||
\tSerial Number: DMI777
|
||||
\tUUID: 4c4c4544
|
||||
Handle 0x0002, DMI type 2, 15 bytes
|
||||
Base Board Information
|
||||
\tSerial Number: BOARD1
|
||||
"""
|
||||
|
||||
|
||||
def make_image(root, name, meta=None, disk="sda", parts="sda1 sda2", data=b"x" * 3000, dmi=None, saved_by=None):
|
||||
d = os.path.join(root, name)
|
||||
os.makedirs(d)
|
||||
with open(os.path.join(d, "disk"), "w") as f:
|
||||
f.write(disk + "\n")
|
||||
with open(os.path.join(d, "parts"), "w") as f:
|
||||
f.write(parts + "\n")
|
||||
with open(os.path.join(d, "sda1.ntfs-ptcl-img.zst"), "wb") as f:
|
||||
f.write(data)
|
||||
with open(os.path.join(d, "clonezilla-img"), "w") as f:
|
||||
f.write("This image was saved by Clonezilla at 2025-01-01.\n")
|
||||
if saved_by:
|
||||
f.write(f"Saved by {saved_by}.\n")
|
||||
if dmi:
|
||||
with open(os.path.join(d, "Info-dmi.txt"), "w") as f:
|
||||
f.write(dmi)
|
||||
if meta is not None:
|
||||
with open(os.path.join(d, "pxe-meta.txt"), "w") as f:
|
||||
f.write("".join(f"{k}={v}\n" for k, v in meta.items()))
|
||||
return d
|
||||
|
||||
|
||||
def good_meta(machine, serial="SER1", model="OptiPlex 7090", **kw):
|
||||
m = {"machine": machine, "serial": serial, "model": model, "manufacturer": "Dell Inc.",
|
||||
"disk": "sda", "disk_model": "WDC SSD", "disk_size_bytes": "256060514304",
|
||||
"clonezilla": "clonezilla-live-3.3.3-37-amd64", "bits": "64", "firmware": "uefi",
|
||||
"save": "ok", "check": "ok", "date": "2026-10-15 14:03 EDT"}
|
||||
m.update(kw)
|
||||
return m
|
||||
|
||||
|
||||
class Base(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp(prefix="cz-case-")
|
||||
self.share = os.path.join(self.tmp, "share")
|
||||
os.makedirs(self.share)
|
||||
self.state = os.path.join(self.tmp, "state", "clonezilla-settings.json")
|
||||
p1 = mock.patch.object(config, "CLONEZILLA_SHARE", self.share)
|
||||
p2 = mock.patch.object(config, "CLONEZILLA_STATE", self.state)
|
||||
p1.start()
|
||||
p2.start()
|
||||
self.addCleanup(p1.stop)
|
||||
self.addCleanup(p2.stop)
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
self.c = A.app.test_client()
|
||||
with self.c.session_transaction() as s:
|
||||
s["_csrf_token"] = "t"
|
||||
|
||||
def post(self, url, data=None):
|
||||
d = {"_csrf_token": "t"}
|
||||
d.update(data or {})
|
||||
return self.c.post(url, data=d)
|
||||
|
||||
|
||||
class TestListing(Base):
|
||||
def test_detects_only_image_dirs(self):
|
||||
make_image(self.share, "6501-2026-10-15-1403", good_meta("6501"))
|
||||
os.makedirs(os.path.join(self.share, "random-folder"))
|
||||
os.makedirs(os.path.join(self.share, "only-disk"))
|
||||
open(os.path.join(self.share, "only-disk", "disk"), "w").close() # disk but no image file
|
||||
open(os.path.join(self.share, "disk"), "w").close() # a stray file at the top
|
||||
os.makedirs(os.path.join(self.share, ".hidden-2026-01-01-0000"))
|
||||
names = [i["name"] for i in CZ.list_images()]
|
||||
self.assertEqual(names, ["6501-2026-10-15-1403"])
|
||||
|
||||
def test_symlinked_dir_is_not_listed(self):
|
||||
outside = os.path.join(self.tmp, "outside")
|
||||
os.makedirs(outside)
|
||||
make_image(outside, "6501-2026-01-01-0000")
|
||||
os.symlink(os.path.join(outside, "6501-2026-01-01-0000"), os.path.join(self.share, "6501-2026-01-01-0000"))
|
||||
self.assertEqual(CZ.list_images(), [])
|
||||
|
||||
def test_meta_is_read(self):
|
||||
make_image(self.share, "6501-2026-10-15-1403", good_meta("6501", check_date="2026-10-15 14:40 EDT"))
|
||||
i = CZ.list_images()[0]
|
||||
self.assertEqual((i["machine"], i["machine_source"]), ("6501", "meta"))
|
||||
self.assertEqual(i["serial"], "SER1")
|
||||
self.assertEqual(i["model"], "OptiPlex 7090")
|
||||
self.assertEqual(i["bits"], "64")
|
||||
self.assertEqual(i["clonezilla"], "clonezilla-live-3.3.3-37-amd64")
|
||||
self.assertEqual(i["status"], "ok")
|
||||
self.assertEqual(i["disks"], ["sda"])
|
||||
self.assertEqual(i["parts"], ["sda1", "sda2"])
|
||||
self.assertEqual(i["disk_size"], 256060514304)
|
||||
self.assertGreater(i["size"], 0)
|
||||
|
||||
def test_manual_image_falls_back_to_name_dmi_and_clonezilla_img(self):
|
||||
d = make_image(self.share, "wj-7001-2025-03-04-0915", dmi=DMI, saved_by="clonezilla-live-3.2.0-5-i686")
|
||||
old = time.time() - 30 * 86400
|
||||
os.utime(d, (old, old))
|
||||
i = CZ.list_images()[0]
|
||||
self.assertEqual((i["machine"], i["machine_source"]), ("WJ-7001", "name"))
|
||||
self.assertEqual((i["serial"], i["model"], i["manufacturer"]), ("DMI777", "OptiPlex 7010", "Dell Inc."))
|
||||
self.assertEqual(i["clonezilla"], "clonezilla-live-3.2.0-5-i686")
|
||||
self.assertEqual(i["bits"], "32")
|
||||
self.assertEqual(i["status"], "manual")
|
||||
|
||||
def test_fresh_image_without_meta_is_in_progress(self):
|
||||
make_image(self.share, "6501-2026-10-15-1403")
|
||||
self.assertEqual(CZ.list_images()[0]["status"], "in-progress")
|
||||
|
||||
def test_failed_statuses(self):
|
||||
make_image(self.share, "6501-2026-10-15-1403", good_meta("6501", save="failed", check="not-run"))
|
||||
make_image(self.share, "6501-2026-10-14-1403", good_meta("6501", check="failed"))
|
||||
st = {i["name"]: i["status"] for i in CZ.list_images()}
|
||||
self.assertEqual(st, {"6501-2026-10-15-1403": "failed", "6501-2026-10-14-1403": "check-failed"})
|
||||
|
||||
def test_unreadable_and_odd_contents_never_500(self):
|
||||
make_image(self.share, "6501-2026-10-15-1403", good_meta("6501"))
|
||||
bad = make_image(self.share, "6502-2026-10-15-1403", good_meta("6502"))
|
||||
with open(os.path.join(bad, "pxe-meta.txt"), "wb") as f:
|
||||
f.write(b"\xff\xfe machine=\x00garbage\nno equals sign\n=\n")
|
||||
os.makedirs(os.path.join(self.share, "locked-2026-01-01-0000"))
|
||||
os.chmod(os.path.join(self.share, "locked-2026-01-01-0000"), 0)
|
||||
self.addCleanup(os.chmod, os.path.join(self.share, "locked-2026-01-01-0000"), 0o755)
|
||||
with open(os.path.join(self.share, "weird \u00e9 name.zip"), "wb") as f:
|
||||
f.write(b"PK")
|
||||
r = self.c.get("/backups")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertIn(b"6501-2026-10-15-1403", r.data)
|
||||
|
||||
def test_share_missing_still_renders(self):
|
||||
shutil.rmtree(self.share)
|
||||
r = self.c.get("/backups")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertIn(b"No backups yet", r.data)
|
||||
|
||||
|
||||
class TestGrouping(Base):
|
||||
def test_grouped_newest_first_with_zips_and_unassigned(self):
|
||||
make_image(self.share, "6501-2026-10-15-1403", good_meta("6501"))
|
||||
make_image(self.share, "6501-2026-09-01-0800", good_meta("6501"))
|
||||
make_image(self.share, "6501-2026-10-15-1403-2", good_meta("6501"))
|
||||
make_image(self.share, "6502-2026-08-01-0800", good_meta("6502", serial="S2"))
|
||||
d = make_image(self.share, "2026-07-01-10-img") # Clonezilla's default autoname
|
||||
old = time.time() - 30 * 86400
|
||||
os.utime(d, (old, old))
|
||||
with open(os.path.join(self.share, "6502.zip"), "wb") as f:
|
||||
f.write(b"PK")
|
||||
groups = CZ.grouped()
|
||||
self.assertEqual([g["machine"] for g in groups], ["6502", "6501", ""]) # zip just written: 6502 newest
|
||||
g6501 = next(g for g in groups if g["machine"] == "6501")
|
||||
self.assertEqual([i["name"] for i in g6501["images"]],
|
||||
["6501-2026-10-15-1403-2", "6501-2026-10-15-1403", "6501-2026-09-01-0800"])
|
||||
g6502 = next(g for g in groups if g["machine"] == "6502")
|
||||
self.assertEqual([z["filename"] for z in g6502["zips"]], ["6502.zip"])
|
||||
page = self.c.get("/backups").data
|
||||
self.assertIn(b"No machine number", page)
|
||||
self.assertIn(b"6502.zip", page)
|
||||
|
||||
def test_machine_case_is_normalised(self):
|
||||
make_image(self.share, "wj-1-2026-10-15-1403", good_meta("wj-1"))
|
||||
make_image(self.share, "WJ-1-2026-10-14-1403")
|
||||
self.assertEqual([g["machine"] for g in CZ.grouped()], ["WJ-1"])
|
||||
|
||||
def test_serial_mismatch_flag(self):
|
||||
make_image(self.share, "6501-2026-10-15-1403", good_meta("6501", serial="AAA111"))
|
||||
make_image(self.share, "6501-2026-10-14-1403", good_meta("6501", serial="aaa111 "))
|
||||
make_image(self.share, "6502-2026-10-14-1403", good_meta("6502", serial="X"))
|
||||
groups = {g["machine"]: g for g in CZ.grouped()}
|
||||
self.assertFalse(groups["6501"]["mismatch"])
|
||||
make_image(self.share, "6501-2026-10-13-1403", good_meta("6501", serial="BBB222"))
|
||||
groups = {g["machine"]: g for g in CZ.grouped()}
|
||||
self.assertTrue(groups["6501"]["mismatch"])
|
||||
self.assertEqual(groups["6501"]["serials"], ["AAA111", "BBB222"])
|
||||
self.assertEqual([i["serial_differs"] for i in groups["6501"]["images"]], [False, False, True])
|
||||
self.assertFalse(groups["6502"]["mismatch"])
|
||||
page = self.c.get("/backups").data
|
||||
self.assertIn(b"Serial mismatch", page)
|
||||
self.assertIn(b"different serial numbers", page)
|
||||
|
||||
|
||||
class TestDeleteMove(Base):
|
||||
def test_delete_image(self):
|
||||
make_image(self.share, "6501-2026-10-15-1403", good_meta("6501"))
|
||||
r = self.post("/backups/image/6501-2026-10-15-1403/delete")
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.assertFalse(os.path.exists(os.path.join(self.share, "6501-2026-10-15-1403")))
|
||||
|
||||
def test_delete_needs_csrf(self):
|
||||
make_image(self.share, "6501-2026-10-15-1403", good_meta("6501"))
|
||||
r = self.c.post("/backups/image/6501-2026-10-15-1403/delete")
|
||||
self.assertEqual(r.status_code, 403)
|
||||
self.assertTrue(os.path.isdir(os.path.join(self.share, "6501-2026-10-15-1403")))
|
||||
|
||||
def test_delete_refuses_unsafe_names(self):
|
||||
outside = os.path.join(self.tmp, "outside")
|
||||
make_image(outside, "victim")
|
||||
os.makedirs(os.path.join(self.share, "plain-folder"))
|
||||
os.symlink(os.path.join(outside, "victim"), os.path.join(self.share, "link"))
|
||||
for bad in ("..", ".", "...", "%2e%2e", "..%2Foutside%2Fvictim", "link", "plain-folder", "nope", ".hidden"):
|
||||
with self.subTest(bad=bad):
|
||||
self.post(f"/backups/image/{bad}/delete")
|
||||
for bad in ("..", "../outside/victim", "link", "plain-folder", "", "a/b", "a\\b", "x\x00"):
|
||||
with self.subTest(direct=bad), self.assertRaises(CZ.ImageError):
|
||||
CZ.delete_image(bad)
|
||||
self.assertTrue(os.path.isdir(os.path.join(outside, "victim")))
|
||||
self.assertTrue(os.path.isdir(os.path.join(self.share, "plain-folder")))
|
||||
self.assertTrue(os.path.islink(os.path.join(self.share, "link")))
|
||||
|
||||
def test_move_renames_and_updates_meta(self):
|
||||
make_image(self.share, "6510-2026-10-15-1403", good_meta("6510"))
|
||||
r = self.post("/backups/image/6510-2026-10-15-1403/move", {"machine": "6501"})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
new = os.path.join(self.share, "6501-2026-10-15-1403")
|
||||
self.assertTrue(os.path.isdir(new))
|
||||
meta = CZ.read_meta(new)
|
||||
self.assertEqual(meta["machine"], "6501")
|
||||
self.assertEqual(meta["moved_from"], "6510")
|
||||
self.assertEqual(meta["serial"], "SER1")
|
||||
self.assertEqual([i["name"] for i in CZ.list_images()], ["6501-2026-10-15-1403"])
|
||||
|
||||
def test_move_clash_gets_suffix_and_case_twin_counts(self):
|
||||
make_image(self.share, "6501-2026-10-15-1403", good_meta("6501"))
|
||||
make_image(self.share, "6510-2026-10-15-1403", good_meta("6510"))
|
||||
self.assertEqual(CZ.move_image("6510-2026-10-15-1403", "6501"), "6501-2026-10-15-1403-2")
|
||||
make_image(self.share, "ab-2026-10-15-1403", good_meta("ab"))
|
||||
make_image(self.share, "x-2026-10-15-1403", good_meta("x"))
|
||||
self.assertEqual(CZ.move_image("x-2026-10-15-1403", "AB"), "AB-2026-10-15-1403-2")
|
||||
|
||||
def test_move_manual_image_without_stamp(self):
|
||||
d = make_image(self.share, "2026-07-01-10-img")
|
||||
os.utime(d, (1700000000, 1700000000))
|
||||
new = CZ.move_image("2026-07-01-10-img", "6501")
|
||||
self.assertRegex(new, r"^6501-\d{4}-\d{2}-\d{2}-\d{4}$")
|
||||
self.assertEqual(CZ.read_meta(os.path.join(self.share, new))["machine"], "6501")
|
||||
|
||||
def test_move_refuses_bad_machine(self):
|
||||
make_image(self.share, "6510-2026-10-15-1403", good_meta("6510"))
|
||||
for bad in ("", "-6501", "65 01", "../x", "a/b", "x" * 33, "65_01"):
|
||||
with self.subTest(bad=bad), self.assertRaises(CZ.ImageError):
|
||||
CZ.move_image("6510-2026-10-15-1403", bad)
|
||||
self.assertTrue(os.path.isdir(os.path.join(self.share, "6510-2026-10-15-1403")))
|
||||
|
||||
|
||||
class TestRetention(Base):
|
||||
def _three(self):
|
||||
make_image(self.share, "6501-2026-10-15-1403", good_meta("6501"))
|
||||
make_image(self.share, "6501-2026-10-01-1403", good_meta("6501"))
|
||||
make_image(self.share, "6501-2026-09-01-1403", good_meta("6501"))
|
||||
make_image(self.share, "6502-2026-09-01-1403", good_meta("6502"))
|
||||
|
||||
def test_settings_validate_and_persist(self):
|
||||
self.assertEqual(CZ.settings(), {"keep_last": 0, "warn_percent": 85})
|
||||
r = self.post("/backups/settings", {"keep_last": "2", "warn_percent": "90"})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
with open(self.state) as f:
|
||||
self.assertEqual(json.load(f), {"keep_last": 2, "warn_percent": 90})
|
||||
for bad in (("-1", "85"), ("101", "85"), ("2", "40"), ("x", "85")):
|
||||
with self.subTest(bad=bad), self.assertRaises(CZ.ImageError):
|
||||
CZ.save_settings(*bad)
|
||||
self.assertEqual(CZ.settings()["keep_last"], 2)
|
||||
|
||||
def test_preview_lists_without_deleting(self):
|
||||
self._three()
|
||||
CZ.save_settings(1, 85)
|
||||
r = self.c.get("/backups/retention")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertIn(b"6501-2026-10-01-1403", r.data)
|
||||
self.assertIn(b"6501-2026-09-01-1403", r.data)
|
||||
self.assertIn(b"will be DELETED", r.data)
|
||||
self.assertEqual(len(CZ.list_images()), 4)
|
||||
|
||||
def test_plan_keeps_newest_good_and_newer_failed(self):
|
||||
make_image(self.share, "6501-2026-10-16-0900", good_meta("6501", save="failed")) # newer, failed: kept
|
||||
make_image(self.share, "6501-2026-10-15-1403", good_meta("6501", check="failed")) # check failed: not good
|
||||
make_image(self.share, "6501-2026-10-01-1403", good_meta("6501")) # newest good: kept
|
||||
make_image(self.share, "6501-2026-09-01-1403", good_meta("6501")) # delete
|
||||
d = make_image(self.share, "2026-07-01-10-img") # unassigned: never
|
||||
os.utime(d, (1700000000, 1700000000))
|
||||
plan = [p["name"] for p in CZ.retention_plan(1)]
|
||||
self.assertEqual(plan, ["6501-2026-09-01-1403"])
|
||||
self.assertEqual(CZ.retention_plan(0), [])
|
||||
self.assertEqual(CZ.retention_plan(5), [])
|
||||
|
||||
def test_apply_deletes_only_planned_names(self):
|
||||
self._three()
|
||||
CZ.save_settings(1, 85)
|
||||
r = self.c.post("/backups/retention/apply", data={
|
||||
"_csrf_token": "t",
|
||||
"name": ["6501-2026-10-01-1403", "6501-2026-09-01-1403", "6501-2026-10-15-1403", "6502-2026-09-01-1403", "../x"]})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
left = sorted(i["name"] for i in CZ.list_images())
|
||||
self.assertEqual(left, ["6501-2026-10-15-1403", "6502-2026-09-01-1403"])
|
||||
|
||||
def test_stale_preview_cannot_delete_a_now_kept_image(self):
|
||||
self._three()
|
||||
CZ.save_settings(1, 85)
|
||||
planned = [p["name"] for p in CZ.retention_plan()]
|
||||
shutil.rmtree(os.path.join(self.share, "6501-2026-10-15-1403")) # newest removed meanwhile
|
||||
deleted, skipped = CZ.apply_retention(planned)
|
||||
self.assertEqual(deleted, ["6501-2026-09-01-1403"])
|
||||
self.assertEqual(skipped, ["6501-2026-10-01-1403"])
|
||||
self.assertTrue(os.path.isdir(os.path.join(self.share, "6501-2026-10-01-1403")))
|
||||
|
||||
def test_apply_needs_csrf_and_setting(self):
|
||||
self._three()
|
||||
r = self.c.post("/backups/retention/apply", data={"name": ["6501-2026-09-01-1403"]})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
self.post("/backups/retention/apply", {"name": "6501-2026-09-01-1403"}) # keep_last 0: nothing
|
||||
self.assertEqual(len(CZ.list_images()), 4)
|
||||
|
||||
|
||||
class TestTarAndSpace(Base):
|
||||
def test_tar_download_streams_the_folder(self):
|
||||
data = os.urandom(3 * 1024 * 1024 + 123)
|
||||
d = make_image(self.share, "6501-2026-10-15-1403", good_meta("6501"), data=data)
|
||||
os.makedirs(os.path.join(d, "sub"))
|
||||
with open(os.path.join(d, "sub", "f.txt"), "w") as f:
|
||||
f.write("hello")
|
||||
os.symlink("/etc/passwd", os.path.join(d, "evil-link"))
|
||||
r = self.c.get("/backups/image/6501-2026-10-15-1403/tar")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.mimetype, "application/x-tar")
|
||||
self.assertIn('filename="6501-2026-10-15-1403.tar"', r.headers["Content-Disposition"])
|
||||
self.assertTrue(r.is_streamed)
|
||||
body = r.get_data()
|
||||
self.assertEqual(len(body) % tarfile.RECORDSIZE, 0)
|
||||
with tarfile.open(fileobj=io.BytesIO(body)) as t:
|
||||
names = t.getnames()
|
||||
self.assertIn("6501-2026-10-15-1403/sda1.ntfs-ptcl-img.zst", names)
|
||||
self.assertIn("6501-2026-10-15-1403/sub/f.txt", names)
|
||||
self.assertNotIn("6501-2026-10-15-1403/evil-link", names)
|
||||
self.assertEqual(t.extractfile("6501-2026-10-15-1403/sda1.ntfs-ptcl-img.zst").read(), data)
|
||||
self.assertEqual(t.extractfile("6501-2026-10-15-1403/sub/f.txt").read(), b"hello")
|
||||
|
||||
def test_tar_of_shrinking_file_stays_valid(self):
|
||||
d = make_image(self.share, "6501-2026-10-15-1403", good_meta("6501"), data=b"y" * 5000)
|
||||
gen = CZ.tar_stream("6501-2026-10-15-1403")
|
||||
chunks = []
|
||||
for c in gen:
|
||||
chunks.append(c)
|
||||
if len(chunks) == 1: # first header out: shrink a file before it is read
|
||||
with open(os.path.join(d, "sda1.ntfs-ptcl-img.zst"), "wb") as f:
|
||||
f.write(b"y" * 10)
|
||||
with tarfile.open(fileobj=io.BytesIO(b"".join(chunks))) as t:
|
||||
m = t.getmember("6501-2026-10-15-1403/sda1.ntfs-ptcl-img.zst")
|
||||
self.assertEqual(len(t.extractfile(m).read()), m.size)
|
||||
|
||||
def test_tar_bad_name_redirects(self):
|
||||
for bad in ("..", "nope", "plain"):
|
||||
with self.subTest(bad=bad):
|
||||
r = self.c.get(f"/backups/image/{bad}/tar")
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
def test_disk_space_and_warning(self):
|
||||
make_image(self.share, "6501-2026-10-15-1403", good_meta("6501"))
|
||||
sp = CZ.disk_space(50)
|
||||
self.assertGreater(sp["total"], 0)
|
||||
fake = shutil._ntuple_diskusage(100, 95, 5)
|
||||
with mock.patch.object(CZ.shutil, "disk_usage", return_value=fake):
|
||||
self.assertTrue(CZ.disk_space(90)["warn"])
|
||||
self.assertFalse(CZ.disk_space(96)["warn"])
|
||||
page = self.c.get("/backups").data
|
||||
self.assertIn(b"Share disk space", page)
|
||||
self.assertIn(b"95% full", page)
|
||||
|
||||
def test_zip_routes_still_work(self):
|
||||
with open(os.path.join(self.share, "6501.zip"), "wb") as f:
|
||||
f.write(b"PK-zip")
|
||||
r = self.c.get("/backups/download/6501.zip")
|
||||
self.assertEqual(r.data, b"PK-zip")
|
||||
r.close()
|
||||
self.post("/backups/delete/6501.zip")
|
||||
self.assertFalse(os.path.exists(os.path.join(self.share, "6501.zip")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user