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:
cproudlock
2026-10-15 15:26:30 -04:00
parent dde1aacccb
commit 9d56a5369e
11 changed files with 1991 additions and 67 deletions

View File

@@ -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:

View File

@@ -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")

View 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

View File

@@ -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">&#9662;</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">&#9662;</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">&lt;machine&gt;-&lt;YYYY-MM-DD-HHMM&gt;</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">&lt;machine&gt;-&lt;same date&gt;</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 %}

View 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()